Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33a6342a07 | ||
|
|
75548812d0 | ||
|
|
5b96f53930 | ||
|
|
d354709939 | ||
|
|
3cd80ead81 | ||
|
|
9e2ffdb758 | ||
|
|
9718b8a78f | ||
|
|
ee844564de | ||
|
|
04b6431726 | ||
|
|
0c6f8e2d68 | ||
|
|
4fe0405432 | ||
|
|
1f0f75baf8 | ||
|
|
7f95a71426 | ||
|
|
08f4b61523 | ||
|
|
f5ffe81c72 | ||
|
|
656e238a59 | ||
|
|
c170d6091e | ||
|
|
ae60d58893 | ||
|
|
68982e9a85 | ||
|
|
eb9e2db41a | ||
|
|
b59c6856bd | ||
|
|
a00817b93e | ||
|
|
7398261c50 | ||
|
|
73f3ec51f7 | ||
|
|
6c39204301 | ||
|
|
0c3089344b | ||
|
|
6ab606fa54 | ||
|
|
96390110f8 | ||
|
|
6f2f9236b0 | ||
|
|
efb61107fe | ||
|
|
16e780c2df | ||
|
|
f3bf0b2f5c | ||
|
|
1f74e4d234 | ||
|
|
19c2de5f61 | ||
|
|
5ae2bad549 | ||
|
|
e487aa78f3 | ||
|
|
b9079fe4e2 | ||
|
|
206a6bff09 | ||
|
|
6ee31ae45d | ||
|
|
521f8266cf | ||
|
|
1f5e5759cc |
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+358
-43
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertCircle, Antenna, Bell, CheckCircle2, Clock, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Trash2, Unlock, X, Zap,
|
Maximize2, Minimize2, Mic, MessageSquare, Pencil, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
||||||
|
GetOfflineStatus, GetPendingQSOs, RetryOfflineSync,
|
||||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
||||||
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
|
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
|
||||||
ContestDupe,
|
ContestDupe,
|
||||||
@@ -26,6 +27,8 @@ import {
|
|||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||||
GetCATSettings,
|
GetCATSettings,
|
||||||
|
GetSolarData,
|
||||||
|
LoTWUserInfo,
|
||||||
OperatingDefaultForBand,
|
OperatingDefaultForBand,
|
||||||
LogUDPLoggedADIF,
|
LogUDPLoggedADIF,
|
||||||
ListCountries,
|
ListCountries,
|
||||||
@@ -64,6 +67,7 @@ import { IcomPanel } from '@/components/IcomPanel';
|
|||||||
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
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 { 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';
|
||||||
@@ -366,6 +370,35 @@ export default function App() {
|
|||||||
|
|
||||||
// CAT — receives live rig state via Wails events.
|
// CAT — receives live rig state via Wails events.
|
||||||
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
const [catState, setCatState] = useState<CATState>({ enabled: false, connected: false } as any);
|
||||||
|
// Configured CAT backend ('icom' USB vs 'icom-net'): the live catState.backend
|
||||||
|
// is "icom" for BOTH, so we track the settings value to tell them apart (used to
|
||||||
|
// hide the rig ON/OFF buttons on USB, where the interface is unpowered when the
|
||||||
|
// rig is off so power-ON can't work).
|
||||||
|
const [catBackend, setCatBackend] = useState('');
|
||||||
|
// Live space-weather (solar flux / sunspots / A / K) for the header strip.
|
||||||
|
// Loaded on mount, refreshed on the backend 'solar:update' event, plus a slow
|
||||||
|
// fallback poll. These same numbers are stamped onto each logged QSO.
|
||||||
|
const [solar, setSolar] = useState<any>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const load = () => { GetSolarData().then((d) => setSolar(d ?? null)).catch(() => {}); };
|
||||||
|
load();
|
||||||
|
const off = EventsOn('solar:update', load);
|
||||||
|
const id = window.setInterval(load, 60 * 60 * 1000); // hourly fallback; the event refreshes it live
|
||||||
|
return () => { off(); window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
// LoTW-user lookup for the entered callsign (from the cached ARRL activity list),
|
||||||
|
// debounced. Drives a colour-coded LoTW badge by last-upload recency.
|
||||||
|
const [lotwInfo, setLotwInfo] = useState<{ is_user: boolean; last_upload?: string; days_ago: number } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const c = callsign.trim();
|
||||||
|
if (!c) { setLotwInfo(null); return; }
|
||||||
|
const run = () => { LoTWUserInfo(c).then((r) => setLotwInfo(r as any)).catch(() => setLotwInfo(null)); };
|
||||||
|
const t = window.setTimeout(run, 300);
|
||||||
|
// Re-run when the background auto-download finishes (so a call typed before
|
||||||
|
// the list loaded gets its badge without re-typing).
|
||||||
|
const off = EventsOn('lotwusers:updated', run);
|
||||||
|
return () => { window.clearTimeout(t); off(); };
|
||||||
|
}, [callsign]);
|
||||||
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
|
const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 });
|
||||||
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
|
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
|
||||||
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
|
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
|
||||||
@@ -523,13 +556,20 @@ export default function App() {
|
|||||||
if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
|
if (toastTimer.current !== undefined) { window.clearTimeout(toastTimer.current); toastTimer.current = undefined; }
|
||||||
advanceToast(); // skip to the next queued toast (or clear if none)
|
advanceToast(); // skip to the next queued toast (or clear if none)
|
||||||
}, [advanceToast]);
|
}, [advanceToast]);
|
||||||
|
// Status-bar message expanded into a popover (long errors — a TQSL or Club Log
|
||||||
|
// failure — don't fit on one line, and truncating them with no way to read the
|
||||||
|
// rest is useless).
|
||||||
|
const [msgOpen, setMsgOpen] = useState(false);
|
||||||
|
// Reset when the message goes away, or the NEXT one would pop open by itself.
|
||||||
|
useEffect(() => { if (!error && !toast) setMsgOpen(false); }, [error, toast]);
|
||||||
// Error banners auto-dismiss after a few seconds (longer than toasts since
|
// Error banners auto-dismiss after a few seconds (longer than toasts since
|
||||||
// they may be multi-line). The X button still closes them immediately.
|
// they may be multi-line). The X button still closes them immediately. Do NOT
|
||||||
|
// dismiss while the operator has it expanded and is reading it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!error) return;
|
if (!error || msgOpen) return;
|
||||||
const t = window.setTimeout(() => setError(''), 6000);
|
const t = window.setTimeout(() => setError(''), 6000);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [error]);
|
}, [error, msgOpen]);
|
||||||
// True while the QSO recorder is capturing the current contact (set when we
|
// True while the QSO recorder is capturing the current contact (set when we
|
||||||
// leave the callsign field, cleared on log/cancel). Drives the REC badge.
|
// leave the callsign field, cleared on log/cancel). Drives the REC badge.
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
@@ -577,6 +617,12 @@ export default function App() {
|
|||||||
setQslTabOpen(false);
|
setQslTabOpen(false);
|
||||||
setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
|
setActiveTab((t) => (t === 'qsl' ? 'recent' : t));
|
||||||
}
|
}
|
||||||
|
// Statistics is likewise a closable tab, opened from Tools → Statistics.
|
||||||
|
const [statsTabOpen, setStatsTabOpen] = useState(false);
|
||||||
|
function closeStatsTab() {
|
||||||
|
setStatsTabOpen(false);
|
||||||
|
setActiveTab((t) => (t === 'stats' ? '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
|
||||||
@@ -799,6 +845,24 @@ export default function App() {
|
|||||||
type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number };
|
type RecentAlert = { id: number; rule: string; call: string; band: string; mode: string; freq_hz: number; country: string; comment: string; at: number };
|
||||||
const [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
|
const [recentAlerts, setRecentAlerts] = useState<RecentAlert[]>([]);
|
||||||
const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown
|
const [alertsPanelOpen, setAlertsPanelOpen] = useState(false); // LED dropdown
|
||||||
|
|
||||||
|
// Offline outbox: QSOs parked locally because the database was unreachable.
|
||||||
|
const [offlineStatus, setOfflineStatus] = useState<{ offline: boolean; pending: number; path: string }>({ offline: false, pending: 0, path: '' });
|
||||||
|
const [pendingOpen, setPendingOpen] = useState(false);
|
||||||
|
const [pendingList, setPendingList] = useState<any[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
const load = () => GetOfflineStatus().then((s: any) => setOfflineStatus(s)).catch(() => {});
|
||||||
|
load();
|
||||||
|
const off = EventsOn('offline:status', (s: any) => {
|
||||||
|
setOfflineStatus(s);
|
||||||
|
// Keep the open list in step as QSOs are parked or replayed.
|
||||||
|
if (pendingOpenRef.current) GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {});
|
||||||
|
});
|
||||||
|
return () => { off?.(); };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
const pendingOpenRef = useRef(false);
|
||||||
|
useEffect(() => { pendingOpenRef.current = pendingOpen; }, [pendingOpen]);
|
||||||
const ALERT_TTL_MS = 10 * 60 * 1000;
|
const ALERT_TTL_MS = 10 * 60 * 1000;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.setInterval(() => {
|
const id = window.setInterval(() => {
|
||||||
@@ -848,6 +912,31 @@ export default function App() {
|
|||||||
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
|
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
|
||||||
const [clusterGroup, setClusterGroup] = useState(true);
|
const [clusterGroup, setClusterGroup] = useState(true);
|
||||||
const [clusterCmd, setClusterCmd] = useState('');
|
const [clusterCmd, setClusterCmd] = useState('');
|
||||||
|
// Cluster console: the raw traffic. Spots are parsed out of the stream into the
|
||||||
|
// grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded,
|
||||||
|
// so a command appeared to do nothing at all.
|
||||||
|
type ClusterLine = { server_id: number; server_name: string; text: string; sent: boolean; at: string };
|
||||||
|
const CONSOLE_CAP = 2000; // a busy cluster runs for hours — don't grow forever
|
||||||
|
const [clusterLines, setClusterLines] = useState<ClusterLine[]>([]);
|
||||||
|
const [clusterConsoleOpen, setClusterConsoleOpen] = useState(false);
|
||||||
|
const clusterConsoleRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const off = EventsOn('cluster:line', (l: any) => {
|
||||||
|
setClusterLines((prev) => {
|
||||||
|
const next = [...prev, l as ClusterLine];
|
||||||
|
return next.length > CONSOLE_CAP ? next.slice(next.length - CONSOLE_CAP) : next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => { off?.(); };
|
||||||
|
}, []);
|
||||||
|
// Follow the tail, but ONLY when already at the bottom — otherwise scrolling up
|
||||||
|
// to read a SH/DX reply would yank you back down on the next spot.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = clusterConsoleRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||||
|
if (atBottom) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [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>>(new Set());
|
||||||
// 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
|
||||||
@@ -1091,22 +1180,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';
|
||||||
@@ -1352,6 +1448,7 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const c = await GetCATSettings();
|
const c = await GetCATSettings();
|
||||||
if (c.digital_default) digitalDefaultRef.current = c.digital_default;
|
if (c.digital_default) digitalDefaultRef.current = c.digital_default;
|
||||||
|
setCatBackend(c.backend ?? '');
|
||||||
} catch {}
|
} catch {}
|
||||||
}, []);
|
}, []);
|
||||||
const loadLists = useCallback(async () => {
|
const loadLists = useCallback(async () => {
|
||||||
@@ -1956,7 +2053,13 @@ export default function App() {
|
|||||||
srx: rE.num, srx_string: rE.str,
|
srx: rE.num, srx_string: rE.str,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await AddQSO(payload);
|
// id = -1 means the database was unreachable and the QSO was parked in the
|
||||||
|
// offline outbox — it is SAVED (on disk), just not in the logbook yet. Treat
|
||||||
|
// it as a success so logging never stops; the banner tells the operator.
|
||||||
|
const newId = await AddQSO(payload);
|
||||||
|
if (typeof newId === 'number' && newId < 0) {
|
||||||
|
showToast(t('offline.queued'));
|
||||||
|
}
|
||||||
// Advance the sent serial only after a successful log.
|
// Advance the sent serial only after a successful log.
|
||||||
if (contest.active && contest.code && contest.exchange === 'serial') {
|
if (contest.active && contest.code && contest.exchange === 'serial') {
|
||||||
updateContest({ nextSerial: contest.nextSerial + 1 });
|
updateContest({ nextSerial: contest.nextSerial + 1 });
|
||||||
@@ -2391,6 +2494,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('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' },
|
||||||
@@ -2426,6 +2530,7 @@ export default function App() {
|
|||||||
case 'edit.bulkedit': openBulkEdit(selectedIds); break;
|
case 'edit.bulkedit': openBulkEdit(selectedIds); break;
|
||||||
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.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;
|
||||||
@@ -2725,9 +2830,82 @@ export default function App() {
|
|||||||
<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>
|
||||||
);
|
);
|
||||||
|
// LoTW-user badge: shown only when the entered call is a LoTW user, coloured by
|
||||||
|
// last-upload recency (green < 1 week · amber 1–4 weeks · red > 30 days). The
|
||||||
|
// tooltip shows the exact last-upload date. Needs the list downloaded once
|
||||||
|
// (Settings → LoTW); until then no badge appears.
|
||||||
|
const lotwBlock = (lotwInfo && lotwInfo.is_user) ? (() => {
|
||||||
|
const d = lotwInfo.days_ago ?? -1;
|
||||||
|
const cls = d >= 0 && d < 7 ? 'border-success text-success bg-success/15'
|
||||||
|
: d < 30 ? 'border-warning text-warning bg-warning/15'
|
||||||
|
: 'border-danger text-danger bg-danger/15';
|
||||||
|
return (
|
||||||
|
<div className="self-end shrink-0" title={t('lotw.userTip', { date: lotwInfo.last_upload || '?', days: d })}>
|
||||||
|
<span className={cn('inline-flex items-center rounded-md border px-1.5 py-1.5 text-[10px] font-extrabold tracking-wide leading-none', cls)}>
|
||||||
|
LoTW
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})() : null;
|
||||||
// A discreet spot-alert LED (bell) that fills the gap at the right of the Grid
|
// A discreet spot-alert LED (bell) that fills the gap at the right of the Grid
|
||||||
// row instead of the intrusive floating cards. It lights + shows a count when
|
// row instead of the intrusive floating cards. It lights + shows a count when
|
||||||
// alerts are pending; click it to see the last 3 (each clickable to tune).
|
// alerts are pending; click it to see the last 3 (each clickable to tune).
|
||||||
|
// ── Offline outbox (safety net) ──────────────────────────────────────
|
||||||
|
// When the shared database is unreachable, QSOs are parked in a local ADIF
|
||||||
|
// file rather than lost. Surface that HONESTLY: the operator must know their
|
||||||
|
// worked-before check doesn't include these, and see what's waiting.
|
||||||
|
const offlinePendingCount = offlineStatus.pending ?? 0;
|
||||||
|
const offlineBlock = offlinePendingCount === 0 ? null : (
|
||||||
|
<div className="relative self-end mb-0.5 shrink-0">
|
||||||
|
<button type="button" onClick={() => { setPendingOpen((o) => !o); GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {}); }}
|
||||||
|
title={t('offline.tip', { n: offlinePendingCount })}
|
||||||
|
className="relative inline-flex h-8 items-center gap-1.5 rounded-full border border-danger bg-danger/15 px-2.5 text-danger transition-colors hover:bg-danger/25">
|
||||||
|
<CloudOff className="size-4" />
|
||||||
|
<span className="text-xs font-bold tabular-nums">{offlinePendingCount}</span>
|
||||||
|
</button>
|
||||||
|
{pendingOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setPendingOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-9 z-50 w-[26rem] rounded-lg border border-border bg-card shadow-xl overflow-hidden">
|
||||||
|
<div className="px-3 py-2 border-b border-border bg-danger-muted text-danger-muted-foreground">
|
||||||
|
<p className="text-xs font-semibold">{t('offline.title', { n: offlinePendingCount })}</p>
|
||||||
|
<p className="text-[11px] mt-0.5 leading-snug">{t('offline.explain')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 overflow-auto divide-y divide-border/60">
|
||||||
|
{pendingList.length === 0 ? (
|
||||||
|
<p className="px-3 py-3 text-[11px] text-muted-foreground italic">{t('offline.empty')}</p>
|
||||||
|
) : pendingList.map((q, i) => (
|
||||||
|
<div key={i} className="px-3 py-1.5 flex items-center gap-2 text-xs">
|
||||||
|
<span className="font-mono font-semibold w-24 truncate">{q.callsign}</span>
|
||||||
|
<span className="text-muted-foreground w-12">{q.band}</span>
|
||||||
|
<span className="text-muted-foreground w-14">{q.mode}</span>
|
||||||
|
<span className="ml-auto text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{q.qso_date ? new Date(q.qso_date).toISOString().slice(0, 16).replace('T', ' ') : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2 border-t border-border flex items-center gap-2">
|
||||||
|
<button type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const n = await RetryOfflineSync();
|
||||||
|
if (n > 0) showToast(t('offline.synced', { n }));
|
||||||
|
else showToast(t('offline.stillDown'));
|
||||||
|
GetPendingQSOs().then((r: any) => setPendingList((r ?? []) as any[])).catch(() => {});
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}}
|
||||||
|
className="h-7 px-2 rounded border border-border text-xs hover:bg-muted">
|
||||||
|
{t('offline.retry')}
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] text-muted-foreground truncate" title={offlineStatus.path}>{offlineStatus.path}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
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 : (
|
||||||
@@ -2819,6 +2997,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>
|
||||||
@@ -2827,8 +3017,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>
|
||||||
@@ -2840,8 +3032,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')}
|
||||||
/>
|
/>
|
||||||
@@ -3154,7 +3348,7 @@ export default function App() {
|
|||||||
case 'icom':
|
case 'icom':
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
||||||
<IcomPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'netcontrol':
|
case 'netcontrol':
|
||||||
@@ -3216,7 +3410,7 @@ export default function App() {
|
|||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
) : (
|
) : (
|
||||||
<header className="grid grid-cols-[auto_auto_1fr_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
|
<header className="grid grid-cols-[auto_auto_1fr_auto_auto_auto] items-center gap-4 px-4 h-12 bg-card/95 backdrop-blur border-b border-border shrink-0 shadow-sm">
|
||||||
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
<div className="flex items-center gap-2 pr-2 border-r border-border/60">
|
||||||
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
<div className="size-2.5 rounded-full bg-gradient-to-br from-primary to-orange-400 shadow-[0_0_0_3px_rgba(234,88,12,0.18)]" />
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
@@ -3228,25 +3422,8 @@ export default function App() {
|
|||||||
<Menubar menus={menus} onAction={handleMenu} />
|
<Menubar menus={menus} onAction={handleMenu} />
|
||||||
|
|
||||||
<div className="relative flex items-center justify-center gap-2 font-mono">
|
<div className="relative flex items-center justify-center gap-2 font-mono">
|
||||||
{/* Transient toast / error, in the empty band between the menu and
|
{/* Toasts and errors live in the STATUS BAR at the bottom now — the
|
||||||
the frequency (left of centre). Single-line + truncated. */}
|
header band was too narrow and long messages were cut off. */}
|
||||||
{(error || toast) && (
|
|
||||||
<div className="absolute left-1 top-1/2 -translate-y-1/2 z-20 flex items-center max-w-[min(42vw,560px)] font-sans">
|
|
||||||
{error ? (
|
|
||||||
<div className="flex items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/10 text-destructive px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
|
|
||||||
<AlertCircle className="size-3.5 shrink-0" />
|
|
||||||
<span className="truncate" title={error}>{error}</span>
|
|
||||||
<button className="shrink-0 hover:text-destructive/70" onClick={() => setError('')}><X className="size-3" /></button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1.5 rounded-md border border-success-border bg-success-muted text-success-muted-foreground px-2.5 py-1 text-xs shadow min-w-0 animate-in fade-in">
|
|
||||||
<Satellite className="size-3.5 shrink-0" />
|
|
||||||
<span className="truncate" title={toast}>{toast}</span>
|
|
||||||
<button className="shrink-0 text-success hover:text-success" onClick={dismissToast}><X className="size-3" /></button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col items-end leading-none">
|
<div className="flex flex-col items-end leading-none">
|
||||||
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
||||||
{catState.split && rxFreqMhz && (
|
{catState.split && rxFreqMhz && (
|
||||||
@@ -3326,10 +3503,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>
|
||||||
@@ -3439,6 +3616,34 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Space-weather / propagation — compact, in the header. Live from N0NBH
|
||||||
|
(hamqsl.com), auto-refreshed hourly; the same SFI / A / K are stamped
|
||||||
|
onto each logged QSO. Always renders one element so the grid columns
|
||||||
|
stay aligned (empty span until the feed loads). */}
|
||||||
|
{solar && solar.ok ? (
|
||||||
|
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
|
||||||
|
title={`${t('prop.title')}${solar.updated ? ' · ' + solar.updated : ''}`}>
|
||||||
|
{(() => {
|
||||||
|
const geo = String(solar.geomag_field || '').toUpperCase();
|
||||||
|
const geoCls = /STORM|SEVERE/.test(geo) ? 'text-danger'
|
||||||
|
: /ACTIVE|UNSETTLED/.test(geo) ? 'text-warning' : 'text-success';
|
||||||
|
const it = (label: string, val: any, cls = 'text-foreground') => (
|
||||||
|
<span className="inline-flex items-baseline gap-1">
|
||||||
|
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">{label}</span>
|
||||||
|
<span className={cn('font-bold text-[12px]', cls)}>{(val ?? '') === '' ? '—' : val}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (<>
|
||||||
|
{it('SFI', solar.sfi)}
|
||||||
|
{it('SSN', solar.ssn)}
|
||||||
|
{it('A', solar.a_index)}
|
||||||
|
{it('K', solar.k_index)}
|
||||||
|
{geo ? <span className={cn('font-bold text-[12px]', geoCls)}>{geo}</span> : null}
|
||||||
|
</>);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
) : <span />}
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
<div className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground px-2.5 py-1 bg-muted rounded-md border border-border/60">
|
||||||
<Clock className="size-3" />
|
<Clock className="size-3" />
|
||||||
{utcNow}<span className="text-[10px]">UTC</span>
|
{utcNow}<span className="text-[10px]">UTC</span>
|
||||||
@@ -3660,6 +3865,8 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
{qthBlock}
|
{qthBlock}
|
||||||
{gridBlock}
|
{gridBlock}
|
||||||
|
{lotwBlock}
|
||||||
|
{offlineBlock}
|
||||||
{alertLedBlock}
|
{alertLedBlock}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3754,6 +3961,7 @@ export default function App() {
|
|||||||
status={agStatus}
|
status={agStatus}
|
||||||
onActivate={agActivate}
|
onActivate={agActivate}
|
||||||
onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }}
|
onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }}
|
||||||
|
band={band}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -3932,6 +4140,21 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
||||||
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
|
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
|
||||||
|
{statsTabOpen && (
|
||||||
|
<TabsTrigger value="stats" className="gap-1.5">
|
||||||
|
{t('stats.tab')}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label="Close Statistics"
|
||||||
|
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(); closeStatsTab(); }}
|
||||||
|
>
|
||||||
|
<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
|
||||||
@@ -4161,8 +4384,48 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* Console — the RAW cluster stream. Spots are parsed out of it into
|
||||||
|
the grid above, but SH/DX, WHO, the MOTD and error replies are not
|
||||||
|
spots: without this they were dropped and the command box looked
|
||||||
|
inert (you typed SH/DX/100 and nothing ever happened). */}
|
||||||
|
{clusterConsoleOpen && (
|
||||||
|
<div className="border-t border-border/60 shrink-0 flex flex-col" style={{ height: 200 }}>
|
||||||
|
<div className="flex items-center gap-2 px-2.5 py-1 bg-muted/30 border-b border-border/60 shrink-0">
|
||||||
|
<Terminal className="size-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{t('cluster.console')}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums">{clusterLines.length}</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setClusterLines([])}>{t('cluster.clear')}</button>
|
||||||
|
<button className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setClusterConsoleOpen(false)} title={t('cluster.hideConsole')}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref={clusterConsoleRef} className="flex-1 min-h-0 overflow-auto bg-background/40 px-2.5 py-1.5 font-mono text-[11px] leading-[1.45]">
|
||||||
|
{clusterLines.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground italic">{t('cluster.consoleEmpty')}</p>
|
||||||
|
) : clusterLines.map((l, i) => (
|
||||||
|
<div key={i} className={cn('whitespace-pre-wrap break-all', l.sent ? 'text-primary font-semibold' : 'text-foreground/85')}>
|
||||||
|
<span className="text-muted-foreground/60 mr-1.5 select-none">{l.at}</span>
|
||||||
|
{l.sent && <span className="text-muted-foreground/60 mr-1 select-none">»</span>}
|
||||||
|
{l.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Command input — sends to the master server. */}
|
{/* Command input — sends to the master server. */}
|
||||||
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
|
<div className="flex items-center gap-2 p-2.5 border-t border-border/60 shrink-0">
|
||||||
|
{!clusterConsoleOpen && (
|
||||||
|
<button onClick={() => setClusterConsoleOpen(true)} title={t('cluster.showConsole')}
|
||||||
|
className="inline-flex items-center justify-center size-8 rounded-md border border-border hover:bg-muted shrink-0">
|
||||||
|
<Terminal className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap">→ master</span>
|
<span className="text-xs text-muted-foreground font-mono whitespace-nowrap">→ master</span>
|
||||||
<Input
|
<Input
|
||||||
className="font-mono text-xs h-8"
|
className="font-mono text-xs h-8"
|
||||||
@@ -4226,6 +4489,12 @@ export default function App() {
|
|||||||
<AwardsPanel onEditQSO={openEdit} onAwardsChanged={() => setAwardsVersion((v) => v + 1)} />
|
<AwardsPanel onEditQSO={openEdit} onAwardsChanged={() => setAwardsVersion((v) => v + 1)} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{statsTabOpen && (
|
||||||
|
<TabsContent value="stats" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
|
<StatsPanel />
|
||||||
|
</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} />
|
||||||
@@ -4245,7 +4514,7 @@ export default function App() {
|
|||||||
is an Icom. */}
|
is an Icom. */}
|
||||||
{catState.backend === 'icom' && (
|
{catState.backend === 'icom' && (
|
||||||
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
|
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
|
||||||
<IcomPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -4346,7 +4615,7 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<footer className="flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0">
|
<footer className="relative flex items-center gap-2 px-3 h-7 bg-card border-t border-border shrink-0">
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong>
|
QSO count <strong className="text-foreground font-mono">{total.toLocaleString('en-US')}</strong>
|
||||||
</span>
|
</span>
|
||||||
@@ -4365,7 +4634,48 @@ export default function App() {
|
|||||||
disabled={!rotatorHeading.enabled}
|
disabled={!rotatorHeading.enabled}
|
||||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1" />
|
{/* Toasts / errors: the status bar's free space is far wider than the
|
||||||
|
header band they used to sit in. Still one line (the bar is 28px),
|
||||||
|
but CLICK opens the full text wrapped — long messages (a TQSL or
|
||||||
|
Club Log failure) are no longer cut off with no way to read them. */}
|
||||||
|
<div className="flex-1 min-w-0 flex items-center gap-1 px-1">
|
||||||
|
{(error || toast) && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMsgOpen((o) => !o)}
|
||||||
|
title={t('msg.expand')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] min-w-0 max-w-full animate-in fade-in',
|
||||||
|
error
|
||||||
|
? 'border-destructive/40 bg-destructive/10 text-destructive hover:bg-destructive/20'
|
||||||
|
: 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted/70',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{error ? <AlertCircle className="size-3.5 shrink-0" /> : <Satellite className="size-3.5 shrink-0" />}
|
||||||
|
<span className="truncate">{error || toast}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => { setMsgOpen(false); if (error) setError(''); else dismissToast(); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
{msgOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setMsgOpen(false)} />
|
||||||
|
<div className="absolute bottom-7 left-2 z-50 w-[min(70vw,760px)] rounded-lg border border-border bg-card shadow-xl p-3">
|
||||||
|
<p className={cn('text-xs whitespace-pre-wrap break-words leading-relaxed',
|
||||||
|
error ? 'text-destructive' : 'text-foreground')}>
|
||||||
|
{error || toast}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{dbConn && (
|
{dbConn && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -4397,9 +4707,14 @@ export default function App() {
|
|||||||
onClose={() => setShowSpotModal(false)}
|
onClose={() => setShowSpotModal(false)}
|
||||||
// Callsign: the QSO-entry call, else the last logged QSO.
|
// Callsign: the QSO-entry call, else the last logged QSO.
|
||||||
defaultCall={callsign.trim() || qsos[0]?.callsign || ''}
|
defaultCall={callsign.trim() || qsos[0]?.callsign || ''}
|
||||||
// Freq: the entry TX freq (kHz), else the last logged QSO's.
|
// Freq (0.1 kHz): the LIVE CAT frequency in Hz when connected — NOT the
|
||||||
|
// freqMhz display string, which the manual-edit freeze / field locks can
|
||||||
|
// leave stale (that dropped the sub-kHz: on 14134.5 the frozen "14.134"
|
||||||
|
// string spotted 14134). Fall back to the entry field, then the last QSO.
|
||||||
defaultFreqKHz={
|
defaultFreqKHz={
|
||||||
parseFloat(freqMhz) > 0
|
catState.connected && (catState.freq_hz ?? 0) > 0
|
||||||
|
? Math.round(((catState.freq_hz ?? 0) / 1000) * 10) / 10
|
||||||
|
: parseFloat(freqMhz) > 0
|
||||||
? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
|
? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
|
||||||
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
|
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { Antenna, X } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { Antenna, X, ListFilter } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
export type AGAntenna = { index: number; name: string };
|
export type AGAntenna = { index: number; name: string; bands?: number };
|
||||||
export type AGStatus = {
|
export type AGStatus = {
|
||||||
connected: boolean; host?: string; last_error?: string;
|
connected: boolean; host?: string; last_error?: string;
|
||||||
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
||||||
antennas: AGAntenna[];
|
antennas: AGAntenna[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AG_BAND_BITS maps a band name to its bit in the 4O3A Antenna Genius band
|
||||||
|
// bitmask (standard HF+6m order, bit 0 = 160m). Used to show only the antennas
|
||||||
|
// configured for the current band, like the native app. If the mapping ever
|
||||||
|
// proves off on real hardware, the debug log (antgenius: antenna raw …) shows the
|
||||||
|
// device's actual mask so it can be corrected.
|
||||||
|
const AG_BAND_BITS: Record<string, number> = {
|
||||||
|
'160m': 1 << 0, '80m': 1 << 1, '60m': 0, '40m': 1 << 2, '30m': 1 << 3,
|
||||||
|
'20m': 1 << 4, '17m': 1 << 5, '15m': 1 << 6, '12m': 1 << 7, '10m': 1 << 8, '6m': 1 << 9,
|
||||||
|
};
|
||||||
|
|
||||||
|
function bandBit(band?: string): number {
|
||||||
|
if (!band) return 0;
|
||||||
|
return AG_BAND_BITS[band.trim().toLowerCase()] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Format an antenna name: first letter uppercase, the rest lowercase
|
// Format an antenna name: first letter uppercase, the rest lowercase
|
||||||
// (e.g. "DX COMMANDER" → "Dx commander").
|
// (e.g. "DX COMMANDER" → "Dx commander").
|
||||||
function pretty(name: string): string {
|
function pretty(name: string): string {
|
||||||
@@ -22,13 +38,28 @@ function pretty(name: string): string {
|
|||||||
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||||
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
|
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
|
||||||
// Clicking an already-selected port deselects it (port → None).
|
// Clicking an already-selected port deselects it (port → None).
|
||||||
export function AntGeniusPanel({ status, onActivate, onClose }: {
|
export function AntGeniusPanel({ status, onActivate, onClose, band }: {
|
||||||
status: AGStatus;
|
status: AGStatus;
|
||||||
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
band?: string; // current operating band (e.g. "20m") for the band filter
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const list = status.antennas ?? [];
|
const allAntennas = status.antennas ?? [];
|
||||||
|
|
||||||
|
// Band filter (persisted, default ON): show only antennas whose band mask
|
||||||
|
// includes the current band — matching the native 4O3A app. Antennas with an
|
||||||
|
// unknown mask (0) always show, and if the filter would hide EVERYTHING (e.g. a
|
||||||
|
// band we can't map, or masks the device didn't send) we fall back to the full
|
||||||
|
// list so the panel is never mysteriously empty.
|
||||||
|
const [bandFilter, setBandFilter] = useState(() => localStorage.getItem('opslog.agBandFilter') !== '0');
|
||||||
|
const toggleBandFilter = () => setBandFilter((v) => { const n = !v; localStorage.setItem('opslog.agBandFilter', n ? '1' : '0'); return n; });
|
||||||
|
const bit = bandBit(band);
|
||||||
|
let list = allAntennas;
|
||||||
|
if (bandFilter && bit !== 0) {
|
||||||
|
const filtered = allAntennas.filter((a) => !a.bands || (a.bands & bit) !== 0);
|
||||||
|
if (filtered.length > 0) list = filtered;
|
||||||
|
}
|
||||||
|
|
||||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
||||||
const letter = port === 1 ? 'A' : 'B';
|
const letter = port === 1 ? 'A' : 'B';
|
||||||
@@ -57,6 +88,11 @@ export function AntGeniusPanel({ status, onActivate, onClose }: {
|
|||||||
<Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
<Antenna className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
||||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
||||||
<span className="flex-1" />
|
<span className="flex-1" />
|
||||||
|
<button type="button" onClick={toggleBandFilter}
|
||||||
|
title={bandFilter ? t('agp.filterOnHint', { band: band || '' }) : t('agp.filterOffHint')}
|
||||||
|
className={cn('transition-colors', bandFilter ? 'text-primary' : 'text-muted-foreground/50 hover:text-muted-foreground')}>
|
||||||
|
<ListFilter className="size-3.5" />
|
||||||
|
</button>
|
||||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
||||||
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
|
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
|
||||||
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
|
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('agp.online') : t('agp.offline')}</span>
|
||||||
|
|||||||
@@ -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 } 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';
|
||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
|
||||||
ListCountries, DXCCForCountry, DXCCName,
|
ListCountries, DXCCForCountry, DXCCName,
|
||||||
PopulateBuiltinReferences, HasBuiltinReferences,
|
PopulateBuiltinReferences, HasBuiltinReferences,
|
||||||
ExportAwards, ImportAwards,
|
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;
|
||||||
@@ -177,7 +178,90 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
loadMeta();
|
loadMeta();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// Codes present in the SHIPPED catalog. Anything in the database that isn't in
|
||||||
|
// here exists only on this machine — it is yours alone, and a reinstall loses it
|
||||||
|
// unless it has been exported. The list flags those.
|
||||||
|
const [catalogCodes, setCatalogCodes] = useState<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
GetCatalogCodes().then((c: any) => setCatalogCodes(((c ?? []) as string[]).map((s) => s.toUpperCase()))).catch(() => {});
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Shipped fixes we did NOT apply, because this award carries the operator's own
|
||||||
|
// changes and we will not overwrite those behind their back. Offered, not forced.
|
||||||
|
type AwardUpdate = { code: string; name: string; from: number; to: number };
|
||||||
|
const [updates, setUpdates] = useState<AwardUpdate[]>([]);
|
||||||
|
const loadUpdates = useCallback(() => {
|
||||||
|
GetAwardUpdates().then((u: any) => setUpdates(Array.isArray(u) ? u : [])).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { if (open) loadUpdates(); }, [open, loadUpdates]);
|
||||||
|
|
||||||
|
// Pending import awaiting the operator's decision on the awards that collide.
|
||||||
|
type ImportEntry = { code: string; name: string; references: number; exists: boolean; mine_name: string; mine_refs: number; protected: boolean };
|
||||||
|
type ImportPreview = { path: string; awards: ImportEntry[] };
|
||||||
|
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
|
||||||
|
const [decisions, setDecisions] = useState<Record<string, string>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!importPreview) return;
|
||||||
|
// Default to the SAFE choice: keep what you have. An import must never destroy
|
||||||
|
// an award because the operator clicked through a dialog without reading it.
|
||||||
|
const d: Record<string, string> = {};
|
||||||
|
for (const e of importPreview.awards) d[e.code] = e.exists ? 'skip' : 'replace';
|
||||||
|
setDecisions(d);
|
||||||
|
}, [importPreview]);
|
||||||
|
|
||||||
const cur = defs[sel];
|
const 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[]) ?? []);
|
||||||
@@ -215,17 +299,39 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
if (p) setErr(t('awed.exportedTo', { path: p }));
|
if (p) setErr(t('awed.exportedTo', { path: p }));
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
// Import an award bundle: definitions are upserted by code, reference lists
|
// Import: LOOK FIRST, then ask.
|
||||||
// replaced. Reloads the editor afterwards.
|
//
|
||||||
|
// This used to merge by code with "imported wins", silently — import a WAPC
|
||||||
|
// someone shared and YOUR WAPC (its province list, its city regexes) was
|
||||||
|
// destroyed without a word. Sharing awards is exactly what we want people to do,
|
||||||
|
// so it must not be a data-loss trap.
|
||||||
async function importAwards() {
|
async function importAwards() {
|
||||||
setErr('');
|
setErr('');
|
||||||
try {
|
try {
|
||||||
const r: any = await ImportAwards();
|
const p: any = await InspectAwardImport();
|
||||||
if (!r || (!r.awards && !r.references)) return; // cancelled
|
if (!p?.path) return; // cancelled
|
||||||
|
const clashes = (p.awards ?? []).filter((e: any) => e.exists);
|
||||||
|
if (clashes.length === 0) {
|
||||||
|
// Nothing collides — nothing to ask. This is the common case.
|
||||||
|
const dec: Record<string, string> = {};
|
||||||
|
for (const e of p.awards) dec[e.code] = 'replace';
|
||||||
|
await applyImport(p.path, dec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setImportPreview(p); // → the collision dialog decides
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyImport(path: string, decisions: Record<string, string>) {
|
||||||
|
try {
|
||||||
|
const r: any = await ApplyAwardImport(path, decisions);
|
||||||
|
setImportPreview(null);
|
||||||
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
|
const [d] = await Promise.all([GetAwardDefs(), loadMeta()]);
|
||||||
setDefs((d ?? []) as any); setSel(0);
|
setDefs((d ?? []) as any); setSel(0);
|
||||||
onSaved();
|
onSaved();
|
||||||
|
if (r?.awards || r?.references) {
|
||||||
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
|
setErr(t('awed.importedMsg', { awards: r.awards, references: r.references }));
|
||||||
|
}
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
async function updateList(code: string) {
|
async function updateList(code: string) {
|
||||||
@@ -261,15 +367,37 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{filtered.map(({ d, i }) => (
|
{filtered.map(({ d, i }) => {
|
||||||
|
// An award that is NOT in the shipped catalog exists only in THIS
|
||||||
|
// database: nobody else has it, and a reinstall loses it unless it
|
||||||
|
// has been exported. That deserves to be visible at a glance, not
|
||||||
|
// discovered the hard way.
|
||||||
|
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
|
||||||
|
// A pending update is only reachable from the award's own banner, so
|
||||||
|
// the list has to say which award to open — otherwise the fix waits
|
||||||
|
// behind a click nobody knows to make.
|
||||||
|
const hasUpdate = updates.some((u) => (u.code ?? '').toUpperCase() === (d.code ?? '').toUpperCase());
|
||||||
|
return (
|
||||||
<button key={i} onClick={() => setSel(i)}
|
<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',
|
||||||
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
|
||||||
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
|
<span className={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 && (
|
||||||
|
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
|
||||||
|
title={t('awed.onlyHereTip')}>
|
||||||
|
{t('awed.onlyHere')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
|
||||||
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
|
<Plus className="size-3.5 mr-1" /> {t('awed.newAward')}
|
||||||
@@ -279,6 +407,37 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
{/* Right: tabbed editor for selected award */}
|
{/* 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>
|
||||||
) : (
|
) : (
|
||||||
@@ -288,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">
|
||||||
@@ -297,6 +457,15 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
|
<Input className="h-8 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>
|
||||||
|
{/* No "Built-in" checkbox: an award OpsLog ships IS built-in, and
|
||||||
|
the catalog derives that on load. Asking the author to tick a
|
||||||
|
box to declare it would be one more step nobody can guess —
|
||||||
|
forget it and the award silently misses every future catalog
|
||||||
|
correction. "Protected" stays: whether an award can be deleted
|
||||||
|
IS a real choice. */}
|
||||||
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.protectedTip')}>
|
||||||
|
<Checkbox checked={!!cur.protected} onCheckedChange={(c) => patch({ protected: !!c })} /> {t('awed.protectedFlag')}
|
||||||
|
</label>
|
||||||
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
<button className="text-muted-foreground hover:text-destructive" title={t('awed.deleteAward')} onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
<Field2 label={t('awed.description')}><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
|
||||||
@@ -338,7 +507,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
}</SelectContent>
|
}</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">
|
||||||
@@ -365,8 +536,10 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
<Field2 label={t('awed.trailingString')}><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Additional OR searches: a QSO earns a reference if the
|
{/* Fallback searches: tried in order, only while nothing
|
||||||
primary rule OR any of these match. */}
|
has matched yet — the first that hits wins (short-circuit),
|
||||||
|
so a value already resolved by the primary rule isn't
|
||||||
|
re-derived differently by a later one. */}
|
||||||
<div className="border-t pt-2.5 space-y-2">
|
<div className="border-t pt-2.5 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
<p className="text-[11px] text-muted-foreground">{t('awed.additionalSearches')} <span className="font-semibold">(OR)</span> {t('awed.orAlsoMatch')}</p>
|
||||||
@@ -423,8 +596,105 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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 ── */}
|
||||||
@@ -449,11 +719,78 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
|
|||||||
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
<Button variant="outline" onClick={importAwards} title={t('awed.importTitle')}>
|
||||||
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
<Upload className="size-3.5 mr-1" /> {t('awed.import')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* The drop folder: put an award JSON here and it installs on restart —
|
||||||
|
no rebuild, nobody to ask. Opening it beats printing a path someone
|
||||||
|
then has to retype. */}
|
||||||
|
<Button variant="ghost" onClick={() => OpenAwardsFolder().catch((e: any) => setErr(String(e?.message ?? e)))}
|
||||||
|
title={t('awed.awardsFolderTip')}>
|
||||||
|
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
|
||||||
|
</Button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
|
||||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* Collision dialog. The import cannot silently replace an award you already
|
||||||
|
have — importing a shared WAPC used to destroy yours (province list, city
|
||||||
|
regexes, band scope) without a word. You decide, per award. */}
|
||||||
|
{importPreview && (
|
||||||
|
<Dialog open onOpenChange={() => setImportPreview(null)}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('awed.importCollisionTitle')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-xs text-muted-foreground -mt-2">{t('awed.importCollisionHint')}</p>
|
||||||
|
<div className="max-h-[50vh] overflow-auto flex flex-col gap-2 mt-2">
|
||||||
|
{importPreview.awards.map((e) => (
|
||||||
|
<div key={e.code} className="rounded-md border border-border p-2.5">
|
||||||
|
<div className="flex items-baseline gap-2 min-w-0">
|
||||||
|
<span className="font-mono font-semibold text-sm">{e.code}</span>
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{e.name}</span>
|
||||||
|
<span className="ml-auto shrink-0 text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{t('awed.importRefs', { n: e.references })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!e.exists ? (
|
||||||
|
<p className="mt-1 text-[11px] text-success-muted-foreground">{t('awed.importNew')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mt-1 text-[11px] text-warning-muted-foreground">
|
||||||
|
{t('awed.importExists', { name: e.mine_name || e.code, n: e.mine_refs })}
|
||||||
|
{e.protected && ` · ${t('awed.importProtected')}`}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
{([
|
||||||
|
['skip', t('awed.importKeepMine')],
|
||||||
|
['replace', t('awed.importReplace')],
|
||||||
|
['copy', t('awed.importCopy', { code: e.code })],
|
||||||
|
] as [string, string][]).map(([v, label]) => (
|
||||||
|
<button key={v} type="button"
|
||||||
|
onClick={() => setDecisions((d) => ({ ...d, [e.code]: v }))}
|
||||||
|
className={cn('px-2 h-7 rounded-md border text-xs',
|
||||||
|
decisions[e.code] === v
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setImportPreview(null)}>{t('awed.cancel')}</Button>
|
||||||
|
<Button onClick={() => applyImport(importPreview.path, decisions)}>
|
||||||
|
<Upload className="size-3.5 mr-1" /> {t('awed.import2')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -606,26 +943,18 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
|
|||||||
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
|
<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
|
||||||
|
the label column and the input is left too narrow to read a group
|
||||||
|
name ("Basilicata" → "Basili"). */}
|
||||||
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
|
<Field2 label={t('awed.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={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
|
||||||
</div>
|
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
import { Plus, Trash2, Save, FolderOpen, X } from 'lucide-react';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
@@ -134,6 +134,12 @@ interface Props {
|
|||||||
|
|
||||||
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
// Field dropdown sorted alphabetically by translated label (the FIELDS array is
|
||||||
|
// grouped by category for maintenance; the operator wants it A→Z to find things).
|
||||||
|
const sortedFields = useMemo(
|
||||||
|
() => FIELDS.map((f) => ({ f, txt: t(f.label) })).sort((a, b) => a.txt.localeCompare(b.txt)),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
const [conditions, setConditions] = useState<FilterCondition[]>([]);
|
const [conditions, setConditions] = useState<FilterCondition[]>([]);
|
||||||
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
|
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
|
||||||
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
|
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
|
||||||
@@ -237,7 +243,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
}}>
|
}}>
|
||||||
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent className="max-h-72">
|
<SelectContent className="max-h-72">
|
||||||
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{t(f.label)}</SelectItem>)}
|
{sortedFields.map(({ f, txt }) => <SelectItem key={f.value} value={f.value}>{txt}</SelectItem>)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
|
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ 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,
|
||||||
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';
|
||||||
@@ -24,7 +26,10 @@ 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;
|
||||||
|
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
|
||||||
mode?: string;
|
mode?: string;
|
||||||
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||||
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
|
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
|
||||||
@@ -34,14 +39,16 @@ type FlexState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
|
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
|
||||||
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
|
type Meter = { id: number; src?: string; name?: string; unit?: string; slice?: number; value: number; lo: number; hi: number };
|
||||||
|
|
||||||
const ZERO: FlexState = {
|
const ZERO: FlexState = {
|
||||||
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
||||||
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
|
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,
|
||||||
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,
|
||||||
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
|
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
|
||||||
amp_available: false, amp_operate: false,
|
amp_available: false, amp_operate: false,
|
||||||
@@ -139,31 +146,93 @@ function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, slide
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OffsetRow — RIT / XIT: a switch plus one signed offset field you scrub with the
|
||||||
|
// wheel (or ± , or the arrow keys once focused; hold Ctrl/Shift for 100 Hz steps).
|
||||||
|
// Deliberately the same control as the Icom panel's ShiftRow, so the two rigs are
|
||||||
|
// driven identically.
|
||||||
|
//
|
||||||
|
// The offset is NOT cleared when the switch goes off: SmartSDR keeps it, so
|
||||||
|
// flipping RIT back on returns you exactly where you were, like the radio's own
|
||||||
|
// knob. Zeroing it is a separate, deliberate act — that is what the 0 button is for.
|
||||||
|
const OFFSET_MAX = 99999; // SmartSDR's limit
|
||||||
|
function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
|
||||||
|
label: string; on: boolean; onToggle: () => void; hz: number; onHz: (v: number) => void;
|
||||||
|
disabled?: boolean; title?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const step = useRef((_d: number) => {});
|
||||||
|
step.current = (d: number) => onHz(Math.max(-OFFSET_MAX, Math.min(OFFSET_MAX, (hz || 0) + d)));
|
||||||
|
|
||||||
|
// React's onWheel is passive, so preventDefault() there is ignored and the panel
|
||||||
|
// scrolls under the cursor instead of the value changing. Bind it natively.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
|
||||||
|
step.current(e.deltaY < 0 ? mag : -mag);
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onKey = (e: React.KeyboardEvent) => {
|
||||||
|
const up = e.key === 'ArrowUp' || e.key === 'ArrowRight';
|
||||||
|
const dn = e.key === 'ArrowDown' || e.key === 'ArrowLeft';
|
||||||
|
if (!up && !dn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
|
||||||
|
step.current(up ? mag : -mag);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2" title={title}>
|
||||||
|
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent="cyan" />
|
||||||
|
<div ref={ref} 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).
|
||||||
const METER_SEGMENTS = 26;
|
const METER_SEGMENTS = 26;
|
||||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title }: {
|
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
||||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||||
segColor?: (frac: number) => string; onClick?: () => void; title?: string;
|
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const span = hi - lo;
|
const span = hi - lo;
|
||||||
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||||
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||||
return (
|
return (
|
||||||
<div onClick={onClick} title={title}
|
<div onClick={onClick} title={title}
|
||||||
className={cn('rounded-lg border border-border/70 px-2.5 py-2 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
||||||
|
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
||||||
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
||||||
<div className="flex items-baseline justify-between gap-1 mb-1.5">
|
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||||||
<span className="text-sm font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90">
|
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
||||||
{display !== undefined ? display : (
|
{display !== undefined ? display : (
|
||||||
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
||||||
<div className="flex gap-[2px] h-3 items-stretch rounded-[3px] bg-black/10 p-[2px]">
|
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
||||||
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
||||||
const on = i < lit;
|
const on = i < lit;
|
||||||
const frac = i / METER_SEGMENTS;
|
const frac = i / METER_SEGMENTS;
|
||||||
@@ -176,7 +245,7 @@ function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, displ
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{extra && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -262,6 +331,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
|
? SSB_BW.reduce((best, bw) => (Math.abs(bw - curBW) < Math.abs(best - curBW) ? bw : best), SSB_BW[0])
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
|
// Compact header readouts for PA voltage + temperature (numbers, not bars) — the
|
||||||
|
// user wanted these in the header to save vertical space in the Meters card.
|
||||||
|
const hdrMeters = st.meters || [];
|
||||||
|
const hdrFind = (name: string) => hdrMeters.find((m) => (m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
||||||
|
const voltM = hdrFind('13.8') || hdrFind('VOLT');
|
||||||
|
const patempM = hdrFind('PATEMP') || hdrFind('PA TEMP');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 overflow-auto bg-background">
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||||
@@ -274,6 +350,23 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span>
|
<span className="text-[11px] text-slate-400">{t('flxp.smartsdrRemote')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{/* Compact PA voltage + temperature readouts (numbers) to save space. */}
|
||||||
|
{!off && (voltM || patempM) && (
|
||||||
|
<div className="flex items-center gap-4 mr-1 font-mono tabular-nums">
|
||||||
|
{voltM && (
|
||||||
|
<span className="flex flex-col items-end leading-none">
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.voltage')}</span>
|
||||||
|
<span className="text-sm font-bold">{voltM.value.toFixed(1)}<span className="text-[9px] text-slate-400 ml-0.5">V</span></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{patempM && (
|
||||||
|
<span className="flex flex-col items-end leading-none">
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-slate-400 font-sans">{t('flxp.paTemp')}</span>
|
||||||
|
<span className={cn('text-sm font-bold', patempM.value >= 70 ? 'text-red-400' : patempM.value >= 55 ? 'text-amber-400' : 'text-slate-100')}>{patempM.value.toFixed(0)}<span className="text-[9px] text-slate-400 ml-0.5">°C</span></span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
|
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
|
||||||
!st.available ? 'bg-muted text-muted-foreground'
|
!st.available ? 'bg-muted text-muted-foreground'
|
||||||
: st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}>
|
: st.transmitting ? 'bg-danger text-danger-foreground shadow-[0_0_16px] shadow-danger/60' : 'bg-success text-success-foreground')}>
|
||||||
@@ -315,6 +408,66 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Live meters (UDP VITA-49 stream) — placed right under the slices. */}
|
||||||
|
<Card icon={Gauge} title={t('flxp.meters')}>
|
||||||
|
{(() => {
|
||||||
|
const meters = st.meters || [];
|
||||||
|
if (off || meters.length === 0) {
|
||||||
|
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
|
||||||
|
}
|
||||||
|
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
|
||||||
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
|
// Radio meters (exclude the amplifier's, which we show separately).
|
||||||
|
const radio = (name: string) => meters.find((m) =>
|
||||||
|
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
||||||
|
// Per-slice (SLC) meters — S-meter — exist once PER SLICE, so pick the
|
||||||
|
// one for the ACTIVE slice; otherwise we'd always show slice A's level.
|
||||||
|
const activeSlice = (st.slices || []).find((s) => s.active)?.index ?? -1;
|
||||||
|
const sliceMeter = (name: string) => {
|
||||||
|
const m = meters.filter((x) => (x.name || '').toUpperCase().includes(name) && !(x.src || '').toUpperCase().includes('AMP'));
|
||||||
|
if (m.length === 0) return undefined;
|
||||||
|
return m.find((x) => (x.src || '').toUpperCase().includes('SLC') && x.slice === activeSlice) || m[0];
|
||||||
|
};
|
||||||
|
const sig = sliceMeter('LEVEL') || sliceMeter('SIGNAL');
|
||||||
|
const fwd = radio('FWDPWR');
|
||||||
|
const swr = radio('SWR');
|
||||||
|
// Mic input level + speech-compression (voltage & PA temp live in the
|
||||||
|
// header now). All already in the meter stream.
|
||||||
|
const mic = meters.find((m) => /MIC/i.test(m.name || '') && !(m.src || '').toUpperCase().includes('AMP'));
|
||||||
|
const comp = radio('COMPPEAK') || radio('COMP');
|
||||||
|
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
|
||||||
|
const sUnit = (dbm: number) => {
|
||||||
|
const sv = (dbm + 127) / 6; // S0 = -127 dBm
|
||||||
|
if (sv >= 9) {
|
||||||
|
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
|
||||||
|
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
|
||||||
|
}
|
||||||
|
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
|
||||||
|
};
|
||||||
|
const cur = [
|
||||||
|
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
||||||
|
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
||||||
|
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||||
|
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
|
||||||
|
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
||||||
|
); })(),
|
||||||
|
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
||||||
|
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
||||||
|
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
||||||
|
); })(),
|
||||||
|
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||||
|
// Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive).
|
||||||
|
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={10} accent="#16a34a"
|
||||||
|
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
|
||||||
|
// Speech compression (dB of gain reduction).
|
||||||
|
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={(comp.hi && comp.hi > 0) ? comp.hi : 25} accent="#0891b2" />,
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Card>
|
||||||
|
|
||||||
{off && (
|
{off && (
|
||||||
<div className="text-center text-sm text-muted-foreground py-6">
|
<div className="text-center text-sm text-muted-foreground py-6">
|
||||||
{t('flxp.waiting')}
|
{t('flxp.waiting')}
|
||||||
@@ -365,6 +518,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">
|
||||||
@@ -391,6 +545,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
|
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
|
||||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* TX audio bandwidth — low/high cut (Hz). Typed locally, committed on
|
||||||
|
blur/Enter so we don't spam a command per keystroke; the hold guard
|
||||||
|
keeps the poll from clobbering the field mid-edit. */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.txFilter')}</span>
|
||||||
|
{(() => {
|
||||||
|
const editTX = (patch: Partial<FlexState>) => { hold.current['tx_filter_low'] = Date.now() + 8000; hold.current['tx_filter_high'] = Date.now() + 8000; setSt((p) => ({ ...p, ...patch })); };
|
||||||
|
const commitTX = () => { hold.current['tx_filter_low'] = Date.now() + 900; hold.current['tx_filter_high'] = Date.now() + 900; FlexSetTXFilter(st.tx_filter_low || 0, st.tx_filter_high || 0).catch(() => {}); };
|
||||||
|
const inp = 'w-[70px] h-8 rounded-md border border-input bg-background px-2 text-xs font-mono tabular-nums disabled:opacity-40';
|
||||||
|
return (<>
|
||||||
|
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_low || 0} className={inp}
|
||||||
|
onChange={(e) => editTX({ tx_filter_low: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
|
||||||
|
<span className="text-muted-foreground text-xs">–</span>
|
||||||
|
<input type="number" step={50} min={0} disabled={off} value={st.tx_filter_high || 0} className={inp}
|
||||||
|
onChange={(e) => editTX({ tx_filter_high: parseInt(e.target.value, 10) || 0 })}
|
||||||
|
onBlur={commitTX} onKeyDown={(e) => { if (e.key === 'Enter') { (e.target as HTMLInputElement).blur(); } }} />
|
||||||
|
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
|
||||||
|
</>);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
{/* Mic profile — SmartSDR named mic-EQ/processor presets. */}
|
||||||
|
{!!st.mic_profiles && st.mic_profiles.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">{t('flxp.micProfile')}</span>
|
||||||
|
<select disabled={off} value={st.mic_profile || ''}
|
||||||
|
onChange={(e) => { const v = e.target.value; hold.current['mic_profile'] = Date.now() + 2000; setSt((p) => ({ ...p, mic_profile: v })); FlexSetMicProfile(v).catch(() => {}); }}
|
||||||
|
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-2 text-xs">
|
||||||
|
{!st.mic_profiles.includes(st.mic_profile || '') && <option value="">—</option>}
|
||||||
|
{st.mic_profiles.map((p) => <option key={p} value={p}>{p}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
|
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
|
||||||
@@ -438,6 +625,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}
|
||||||
@@ -464,6 +663,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
||||||
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
|
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
|
||||||
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
|
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
|
||||||
|
{/* WNB — wideband noise blanker (the extra hardware NR the Flex has). */}
|
||||||
|
<LevelRow label="WNB" on={st.wnb} disabled={rxOff} value={st.wnb_level} accent="amber" sliderAccent="#f59e0b"
|
||||||
|
onToggle={() => change('wnb', !st.wnb, () => FlexSetWNB(!st.wnb))}
|
||||||
|
onLevel={(v) => change('wnb_level', v, () => FlexSetWNBLevel(v))} />
|
||||||
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
||||||
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
||||||
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
||||||
@@ -560,71 +763,39 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {st.amp_fault}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
||||||
)}
|
and shown compact so they sit with the amp controls. */}
|
||||||
|
|
||||||
{/* Live meters (UDP VITA-49 stream) */}
|
|
||||||
<Card icon={Gauge} title={t('flxp.meters')}>
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const meters = st.meters || [];
|
const meters = st.meters || [];
|
||||||
if (off || meters.length === 0) {
|
|
||||||
return <p className="text-[11px] text-muted-foreground text-center py-2">{t('flxp.noMeters')}</p>;
|
|
||||||
}
|
|
||||||
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
|
|
||||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
// Radio meters (exclude the amplifier's, which we show separately).
|
|
||||||
const radio = (name: string) => meters.find((m) =>
|
|
||||||
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
|
||||||
const sig = radio('LEVEL') || radio('SIGNAL');
|
|
||||||
const fwd = radio('FWDPWR');
|
|
||||||
const swr = radio('SWR');
|
|
||||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
|
if (off || amp.length === 0) return null;
|
||||||
: /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c'
|
|
||||||
: /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
|
||||||
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
|
|
||||||
const sUnit = (dbm: number) => {
|
|
||||||
const sv = (dbm + 127) / 6; // S0 = -127 dBm
|
|
||||||
if (sv >= 9) {
|
|
||||||
const over = Math.max(0, Math.round(dbm + 73)); // dB over S9
|
|
||||||
return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over };
|
|
||||||
}
|
|
||||||
return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 };
|
|
||||||
};
|
|
||||||
const cur = [
|
|
||||||
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
|
||||||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
|
||||||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
|
||||||
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
|
|
||||||
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
|
||||||
); })(),
|
|
||||||
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
|
||||||
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
|
||||||
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
|
||||||
); })(),
|
|
||||||
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
|
||||||
].filter(Boolean);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
|
||||||
{amp.length > 0 && (
|
|
||||||
<div className="border-t border-border/60 pt-2 mt-1">
|
|
||||||
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">{t('flxp.amplifierHdr')}</div>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
||||||
{amp.map((m) => {
|
{amp.map((m) => {
|
||||||
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
|
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
||||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
|
return <MeterBar key={m.id} 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} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
|
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||||
|
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
|
||||||
|
// value itself is fine — only the bar's range was wrong. Give it a
|
||||||
|
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
||||||
|
// only override an unusable reported range, not a sane one.
|
||||||
|
let lo = m.lo, hi = m.hi;
|
||||||
|
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||||
|
lo = 0;
|
||||||
|
hi = m.hi >= 25 ? m.hi : 25;
|
||||||
|
}
|
||||||
|
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,6 +58,21 @@ const BANDS: { l: string; hz: number }[] = [
|
|||||||
// SSB by frequency and the rig's data variant for digital modes.
|
// SSB by frequency and the rig's data variant for digital modes.
|
||||||
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
|
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM'];
|
||||||
|
|
||||||
|
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
||||||
|
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
||||||
|
// dB stepped attenuator; the IC-7300/705/7100 have a single 20 dB attenuator; the
|
||||||
|
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
|
||||||
|
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
|
||||||
|
function attOptions(model?: string): { v: string; l: string }[] {
|
||||||
|
const m = (model ?? '').toUpperCase();
|
||||||
|
const OFF = { v: '0', l: 'OFF' };
|
||||||
|
if (/(7610|7700|7800|7850|7851)/.test(m)) {
|
||||||
|
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||||||
|
}
|
||||||
|
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
|
||||||
|
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
|
||||||
|
}
|
||||||
|
|
||||||
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
||||||
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
||||||
function fmtVFO(hz?: number): string {
|
function fmtVFO(hz?: number): string {
|
||||||
@@ -534,7 +549,7 @@ function ScopePanadapter() {
|
|||||||
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
|
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
|
||||||
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
|
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
|
||||||
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
|
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
|
||||||
export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) {
|
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [st, setSt] = useState<IcomState>(ZERO);
|
const [st, setSt] = useState<IcomState>(ZERO);
|
||||||
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||||||
@@ -637,8 +652,12 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
|
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{/* Radio power ON / OFF. Manual by design — the app never wakes the rig
|
{/* Radio power ON / OFF — NETWORK only. Over USB the CI-V interface is
|
||||||
on connect; ON sends the wake preamble then the rig boots ~15 s. */}
|
unpowered while the rig is off, so power-ON can't reach it (OFF works
|
||||||
|
but ON doesn't); hiding both avoids a dead button. On the network the
|
||||||
|
rig's LAN server stays alive in standby, so both work. */}
|
||||||
|
{isNetwork && (
|
||||||
|
<>
|
||||||
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
||||||
<Power className="size-3.5" /> ON
|
<Power className="size-3.5" /> ON
|
||||||
@@ -647,6 +666,8 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
|
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
|
||||||
<Power className="size-3.5" /> OFF
|
<Power className="size-3.5" /> OFF
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -804,7 +825,7 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
|
|||||||
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Att">
|
<Row label="Att">
|
||||||
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
|
<Segmented value={String(st.att)} options={attOptions(cat?.rig)}
|
||||||
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row label={t('icmp.filter')}>
|
<Row label={t('icmp.filter')}>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -80,11 +80,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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +363,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
<DialogContent className="max-w-4xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
|
||||||
<DialogHeader className="flex-row items-baseline gap-2">
|
<DialogHeader className="flex-row items-baseline gap-2">
|
||||||
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
<DialogTitle>{t('qedit.title')}</DialogTitle>
|
||||||
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
<span className="font-mono text-xs text-muted-foreground">#{draft.id} — {draft.callsign}</span>
|
||||||
@@ -571,9 +583,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 +600,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>
|
||||||
|
|||||||
@@ -274,6 +274,12 @@ 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
|
||||||
|
// them whenever the saved column state doesn't cover them — a newly-added
|
||||||
|
// award, an empty cache, or a rebuild that runs before the saved state is
|
||||||
|
// re-applied — which is why "all award columns" kept reappearing. The user's
|
||||||
|
// saved state (a column they explicitly showed) still overrides this.
|
||||||
|
hide: true,
|
||||||
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];
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
GetEmailSettings, SaveEmailSettings, TestEmail,
|
GetEmailSettings, SaveEmailSettings, TestEmail,
|
||||||
QSLGetEmailTemplates, QSLSaveEmailTemplates,
|
QSLGetEmailTemplates, QSLSaveEmailTemplates,
|
||||||
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
|
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
|
||||||
|
AudioStartMonitor, AudioStopMonitor, AudioMonitorActive,
|
||||||
|
AudioStartTX, AudioStopTX, AudioTXActive,
|
||||||
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
ListClusterServers, SaveClusterServer, DeleteClusterServer,
|
||||||
GetClusterAutoConnect, SetClusterAutoConnect,
|
GetClusterAutoConnect, SetClusterAutoConnect,
|
||||||
ConnectClusterServer, DisconnectClusterServer,
|
ConnectClusterServer, DisconnectClusterServer,
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
|
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
GetUIPref, SetUIPref,
|
GetUIPref, SetUIPref,
|
||||||
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
|
||||||
@@ -258,7 +261,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'PstRotator',
|
rotator: 'PstRotator',
|
||||||
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',
|
||||||
@@ -341,6 +344,9 @@ function TreeNodeView({
|
|||||||
// Select and slamming the open dropdown shut on any ambient re-render.
|
// Select and slamming the open dropdown shut on any ambient re-render.
|
||||||
const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = {
|
const THEME_SWATCH: Record<Exclude<ThemeChoice, 'auto'>, { bg: string; card: string; accent: string }> = {
|
||||||
'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
|
'light-warm': { bg: '#e8dfc9', card: '#faf6ea', accent: '#b8410c' },
|
||||||
|
'light-cool': { bg: '#f4f6f8', card: '#ffffff', accent: '#2563eb' },
|
||||||
|
'light-sage': { bg: '#eef1ec', card: '#f8faf6', accent: '#2f855a' },
|
||||||
|
'dim-slate': { bg: '#343b47', card: '#3d4552', accent: '#fb923c' },
|
||||||
'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' },
|
'dark-warm': { bg: '#221d18', card: '#2e2820', accent: '#e07a2e' },
|
||||||
'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' },
|
'dark-graphite': { bg: '#16181d', card: '#1f232b', accent: '#f97316' },
|
||||||
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
|
'high-contrast': { bg: '#000000', card: '#0d0d0d', accent: '#ff7a1a' },
|
||||||
@@ -759,6 +765,20 @@ function FlexBandAntennasPanel({ bands }: { bands: string[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Known Icom models paired with their FACTORY default CI-V address. Picking a
|
||||||
|
// model sets icom_addr so the backend identifies it (civ.ModelName) and the UI
|
||||||
|
// adapts (e.g. the attenuator steps differ by model). Keep in lockstep with
|
||||||
|
// civ.ModelName in internal/cat/civ/civ.go. `addr` is decimal.
|
||||||
|
const ICOM_MODELS: { name: string; addr: number }[] = [
|
||||||
|
{ name: 'IC-705', addr: 0xA4 },
|
||||||
|
{ name: 'IC-7300', addr: 0x94 },
|
||||||
|
{ name: 'IC-7610', addr: 0x98 },
|
||||||
|
{ name: 'IC-7700', addr: 0x88 },
|
||||||
|
{ name: 'IC-7800', addr: 0x80 },
|
||||||
|
{ name: 'IC-9100', addr: 0x7C },
|
||||||
|
{ name: 'IC-9700', addr: 0xA2 },
|
||||||
|
];
|
||||||
|
|
||||||
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) {
|
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
||||||
@@ -795,8 +815,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [bandDraft, setBandDraft] = useState('');
|
const [bandDraft, setBandDraft] = useState('');
|
||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
|
||||||
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '',
|
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
|
||||||
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
|
||||||
digital_default: 'FT8',
|
digital_default: 'FT8',
|
||||||
});
|
});
|
||||||
@@ -806,9 +826,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
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 }>({
|
||||||
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,
|
||||||
});
|
});
|
||||||
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);
|
||||||
@@ -863,6 +883,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
|
||||||
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
|
||||||
const [dvkErr, setDvkErr] = useState('');
|
const [dvkErr, setDvkErr] = useState('');
|
||||||
|
const [monitorOn, setMonitorOn] = useState(false);
|
||||||
|
const [txOn, setTxOn] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
AudioMonitorActive().then(setMonitorOn).catch(() => {});
|
||||||
|
AudioTXActive().then(setTxOn).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const toggleMonitor = async () => {
|
||||||
|
try {
|
||||||
|
if (monitorOn) { await AudioStopMonitor(); setMonitorOn(false); }
|
||||||
|
else { await AudioStartMonitor(); setMonitorOn(true); }
|
||||||
|
} catch (err: any) { setDvkErr(String(err?.message ?? err)); }
|
||||||
|
};
|
||||||
|
const toggleTX = async () => {
|
||||||
|
try {
|
||||||
|
if (txOn) { await AudioStopTX(); setTxOn(false); }
|
||||||
|
else { await AudioStartTX(); setTxOn(true); }
|
||||||
|
} catch (err: any) { setDvkErr(String(err?.message ?? err)); }
|
||||||
|
};
|
||||||
|
|
||||||
// General behaviour prefs (mirrored to the DB so they travel with data/).
|
// General behaviour prefs (mirrored to the DB so they travel with data/).
|
||||||
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
|
||||||
@@ -962,6 +1000,16 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [clublogTesting, setClublogTesting] = useState(false);
|
const [clublogTesting, setClublogTesting] = useState(false);
|
||||||
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [lotwTesting, setLotwTesting] = useState(false);
|
const [lotwTesting, setLotwTesting] = useState(false);
|
||||||
|
// LoTW user-activity list (for the callsign badge): count + last refresh.
|
||||||
|
const [lotwUsers, setLotwUsers] = useState<{ count: number; updated?: string }>({ count: 0 });
|
||||||
|
const [lotwUsersBusy, setLotwUsersBusy] = useState(false);
|
||||||
|
useEffect(() => { GetLoTWUsersStatus().then((s) => setLotwUsers(s as any)).catch(() => {}); }, []);
|
||||||
|
const downloadLotwUsers = async () => {
|
||||||
|
setLotwUsersBusy(true);
|
||||||
|
try { await DownloadLoTWUsers(); const s = await GetLoTWUsersStatus(); setLotwUsers(s as any); }
|
||||||
|
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
|
||||||
|
finally { setLotwUsersBusy(false); }
|
||||||
|
};
|
||||||
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);
|
||||||
@@ -2007,6 +2055,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
|
<Checkbox checked={!!catCfg.flex_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_spots: !!c }))} />
|
||||||
{t('cat.flexSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexSpotsHint')}</span>
|
{t('cat.flexSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexSpotsHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!catCfg.flex_decode_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, flex_decode_spots: !!c }))} />
|
||||||
|
{t('cat.flexDecodeSpots')} <span className="text-xs text-muted-foreground">{t('cat.flexDecodeSpotsHint')}</span>
|
||||||
|
</label>
|
||||||
|
{catCfg.flex_decode_spots && (
|
||||||
|
<div className="col-span-2 flex items-center gap-2 pl-6">
|
||||||
|
<Label className="text-sm">{t('cat.flexDecodeSecs')}</Label>
|
||||||
|
<Input type="number" className="w-24" min={10} max={3600}
|
||||||
|
value={catCfg.flex_decode_secs || 120}
|
||||||
|
onChange={(e) => setCatCfg((s) => ({ ...s, flex_decode_secs: parseInt(e.target.value) || 120 }))} />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('cat.flexDecodeSecsHint')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'icom' && (
|
{catCfg.backend === 'icom' && (
|
||||||
@@ -2034,12 +2095,24 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.icomModel')}</Label>
|
||||||
|
<Select
|
||||||
|
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'}
|
||||||
|
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
|
||||||
|
<SelectItem value="_custom">{t('cat.icomModelOther')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
<Label>{t('cat.civAddr')}</Label>
|
<Label>{t('cat.civAddr')}</Label>
|
||||||
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
|
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
|
||||||
onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} />
|
onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} />
|
||||||
<p className="text-xs text-muted-foreground">{t('cat.civHint')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.civHint')}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'icom-net' && (
|
{catCfg.backend === 'icom-net' && (
|
||||||
@@ -2049,6 +2122,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Input placeholder="192.168.1.60" value={catCfg.icom_net_host ?? ''}
|
<Input placeholder="192.168.1.60" value={catCfg.icom_net_host ?? ''}
|
||||||
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_host: e.target.value }))} />
|
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_host: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.icomModel')}</Label>
|
||||||
|
<Select
|
||||||
|
value={ICOM_MODELS.find((m) => m.addr === (catCfg.icom_addr ?? 0x98))?.name ?? '_custom'}
|
||||||
|
onValueChange={(v) => { const m = ICOM_MODELS.find((x) => x.name === v); if (m) setCatCfg((s) => ({ ...s, icom_addr: m.addr })); }}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ICOM_MODELS.map((m) => <SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>)}
|
||||||
|
<SelectItem value="_custom">{t('cat.icomModelOther')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('cat.civAddr')}</Label>
|
<Label>{t('cat.civAddr')}</Label>
|
||||||
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
|
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
|
||||||
@@ -2065,6 +2150,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} />
|
onChange={(e) => setCatCfg((s) => ({ ...s, icom_net_pass: e.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.icomNetHint')}</p>
|
<p className="col-span-2 text-xs text-muted-foreground">{t('cat.icomNetHint')}</p>
|
||||||
|
<label className="col-span-2 flex items-start gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={!!(catCfg as any).icom_net_audio}
|
||||||
|
onCheckedChange={(c) => setCatCfg((s) => ({ ...s, icom_net_audio: !!c } as any))} />
|
||||||
|
<span>
|
||||||
|
{t('cat.icomNetAudio')}
|
||||||
|
<span className="block text-[11px] text-muted-foreground">{t('cat.icomNetAudioHint')}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'tci' && (
|
{catCfg.backend === 'tci' && (
|
||||||
@@ -2174,16 +2267,68 @@ 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-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('hw.motorType')}</Label>
|
||||||
|
{/* Ultrabeam is TCP only; picking it forces the transport back to TCP
|
||||||
|
so the serial fields never apply to it. */}
|
||||||
|
<Select value={ultrabeam.type ?? 'ultrabeam'}
|
||||||
|
onValueChange={(v) => setUltrabeam((s) => ({ ...s, type: v, transport: v === 'ultrabeam' ? 'tcp' : s.transport }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ultrabeam">Ultrabeam</SelectItem>
|
||||||
|
<SelectItem value="steppir">SteppIR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{isSteppir && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('hw.motorTransport')}</Label>
|
||||||
|
<Select value={ultrabeam.transport ?? 'tcp'}
|
||||||
|
onValueChange={(v) => setUltrabeam((s) => ({ ...s, transport: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tcp">{t('hw.motorTcp')}</SelectItem>
|
||||||
|
<SelectItem value="serial">{t('hw.motorSerial')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isSerial ? (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>{t('hw.motorCom')}</Label>
|
||||||
|
<Input
|
||||||
|
value={ultrabeam.com ?? ''}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, com: e.target.value }))}
|
||||||
|
placeholder="COM3"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('hw.motorBaud')}</Label>
|
||||||
|
<Select value={String(ultrabeam.baud || 9600)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, baud: parseInt(v, 10) || 9600 }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[1200, 4800, 9600, 19200].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Host / IP</Label>
|
<Label>Host / IP</Label>
|
||||||
@@ -2204,6 +2349,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 }))} />
|
||||||
@@ -2225,7 +2374,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
@@ -3485,6 +3634,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* LoTW user list — powers the colour-coded "LoTW" badge next to the
|
||||||
|
entered callsign (green < 1 week · amber 1–4 weeks · red > 30 days). */}
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
<Label className="text-sm font-medium">{t('lotw.usersTitle')}</Label>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('lotw.usersHint')}</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={downloadLotwUsers} disabled={lotwUsersBusy}>
|
||||||
|
<ArrowDown className="size-3.5" /> {lotwUsersBusy ? t('lotw.usersDownloading') : t('lotw.usersDownload')}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{lotwUsers.count > 0
|
||||||
|
? t('lotw.usersLoaded', { n: lotwUsers.count.toLocaleString(), date: lotwUsers.updated ? new Date(lotwUsers.updated).toLocaleDateString() : '?' })
|
||||||
|
: t('lotw.usersNone')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : extSvcTab === 'pota' ? (
|
) : extSvcTab === 'pota' ? (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
@@ -3747,6 +3913,40 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
|
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
|
||||||
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
|
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant={monitorOn ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={toggleMonitor}
|
||||||
|
disabled={!monitorOn && !audioCfg.from_radio}
|
||||||
|
title="Hear the rig's RX audio (From Radio) through your Listening device"
|
||||||
|
>
|
||||||
|
{monitorOn ? '■ Stop listening' : '▶ Listen to radio'}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{monitorOn
|
||||||
|
? 'RX monitor running — From Radio → Listening device.'
|
||||||
|
: 'Live-monitor the rig here (USB codec now; network audio later).'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant={txOn ? 'destructive' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={toggleTX}
|
||||||
|
disabled={!txOn && !audioCfg.to_radio}
|
||||||
|
title="Key PTT and pipe your live mic into the rig (To Radio device)"
|
||||||
|
>
|
||||||
|
{txOn ? '■ Stop talking (TX)' : '🎙 Talk to radio (TX)'}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{txOn
|
||||||
|
? 'TRANSMITTING — mic → To Radio, PTT keyed. Click to stop.'
|
||||||
|
: 'Live mic → rig with PTT (USB now; network TX later).'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
|
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
|
||||||
|
|||||||
@@ -0,0 +1,752 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { BarChart3, Loader2, RefreshCw, Table2 } from 'lucide-react';
|
||||||
|
import { GetLogStats, GetContestRuns } from '../../wailsjs/go/main/App';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Statistics dashboard.
|
||||||
|
//
|
||||||
|
// Colour rules this file obeys (they are not stylistic — they are what keeps the
|
||||||
|
// charts readable and colour-blind-safe):
|
||||||
|
//
|
||||||
|
// • A chart with ONE series uses ONE hue (--chart-1) for every bar. Colour
|
||||||
|
// encodes identity, never a magnitude the bar's length already shows.
|
||||||
|
// • The categorical slots (--chart-1..8) are used in FIXED ORDER and only where
|
||||||
|
// the categories ARE the subject (the continent donut). The order is the
|
||||||
|
// colour-blind-safety mechanism, so it is never shuffled or cycled.
|
||||||
|
// • The donut is for part-to-whole read at a glance. It is NOT used for modes:
|
||||||
|
// CW and SSB are nearly tied, and a donut hides exactly the comparison that
|
||||||
|
// makes them interesting. Close values belong in bars.
|
||||||
|
// • Every value is direct-labelled, so nothing is reachable only by hovering, and
|
||||||
|
// a table view gives the WCAG-clean twin.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Bucket = { key: string; count: number };
|
||||||
|
type Gap = { start: string; end: string; minutes: number };
|
||||||
|
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
||||||
|
type Stats = {
|
||||||
|
total: number; unique_calls: number; entities: number; continents: number;
|
||||||
|
first_qso: string; last_qso: string;
|
||||||
|
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||||
|
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||||
|
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||||
|
// Period / contest metrics.
|
||||||
|
window_start: string; window_end: string; window_hours: number;
|
||||||
|
avg_per_hour: number; avg_per_active: number;
|
||||||
|
on_air_minutes: number; off_air_minutes: number;
|
||||||
|
peak_hour_key: string; peak_hour_count: number; best_60: number;
|
||||||
|
gaps: Gap[]; rate: Bucket[];
|
||||||
|
rate_ops: string[]; rate_by_op: number[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minutes → "3 h 12" (a bare "192 min" makes you do arithmetic to read a break).
|
||||||
|
const dur = (m: number) => (m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}` : `${m} min`);
|
||||||
|
|
||||||
|
const CONTINENT_NAME: Record<string, string> = {
|
||||||
|
EU: 'Europe', NA: 'North America', SA: 'South America',
|
||||||
|
AS: 'Asia', AF: 'Africa', OC: 'Oceania', AN: 'Antarctica',
|
||||||
|
};
|
||||||
|
|
||||||
|
const nf = (n: number) => n.toLocaleString('en-US');
|
||||||
|
|
||||||
|
// ── Shell ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Card({ title, sub, children, className }: { title: string; sub?: string; children: React.ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<section className={cn('rounded-lg border border-border bg-card p-3.5 flex flex-col min-w-0', className)}>
|
||||||
|
<header className="mb-3 shrink-0">
|
||||||
|
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{title}</h3>
|
||||||
|
{sub && <p className="text-[11px] text-muted-foreground/80 mt-0.5">{sub}</p>}
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A headline number IS the chart — a one-bar bar chart would be noise.
|
||||||
|
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card px-4 py-3 min-w-0">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{label}</p>
|
||||||
|
{/* Proportional figures: tabular-nums makes a big standalone number look loose. */}
|
||||||
|
<p className="mt-1 text-[28px] leading-none font-semibold text-foreground">{value}</p>
|
||||||
|
{sub && <p className="mt-1 text-[11px] text-muted-foreground truncate">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Horizontal bars (long category names: modes, operators, entities) ─────────
|
||||||
|
// One series → one hue. The value is direct-labelled, so no reader ever depends
|
||||||
|
// on a tooltip to get a number.
|
||||||
|
|
||||||
|
function HBars({ data, max, empty, share }: { data: Bucket[]; max?: number; empty: string; share?: boolean }) {
|
||||||
|
// max is a display cap for long tails (top entities). Where EVERY row matters —
|
||||||
|
// the operators of a multi-op — it is deliberately not set: a capped chart would
|
||||||
|
// silently drop the 9th operator, and "who worked what" is the whole question.
|
||||||
|
const top = max ? data.slice(0, max) : data;
|
||||||
|
const peak = Math.max(1, ...top.map((d) => d.count));
|
||||||
|
const total = data.reduce((s, d) => s + d.count, 0);
|
||||||
|
if (top.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 min-w-0">
|
||||||
|
{top.map((d) => (
|
||||||
|
<div key={d.key} className="group flex items-center gap-2 min-w-0" title={`${d.key} — ${nf(d.count)}`}>
|
||||||
|
<span className="w-20 shrink-0 truncate text-[11px] text-muted-foreground text-right">{d.key}</span>
|
||||||
|
<div className="flex-1 min-w-0 h-[14px] flex items-center">
|
||||||
|
<div
|
||||||
|
className="h-[10px] rounded-r-[4px] transition-[width] duration-300"
|
||||||
|
style={{ width: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="w-14 shrink-0 text-[11px] text-foreground text-right tabular-nums">{nf(d.count)}</span>
|
||||||
|
{share && (
|
||||||
|
<span className="w-10 shrink-0 text-[11px] text-muted-foreground text-right tabular-nums">
|
||||||
|
{total ? ((d.count / total) * 100).toFixed(0) : 0}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vertical columns (short ordered labels: bands in band-plan order, hours) ──
|
||||||
|
// Sorting bands by COUNT would destroy the band-plan reading; the order is the
|
||||||
|
// information.
|
||||||
|
|
||||||
|
function VBars({ data, empty, height = 150 }: { data: Bucket[]; empty: string; height?: number }) {
|
||||||
|
const peak = Math.max(1, ...data.map((d) => d.count));
|
||||||
|
if (data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
// Thin the x labels once the bars get narrow. A label under EVERY one of 48
|
||||||
|
// hourly bars is unreadable mush — the axis exists to orient, not to enumerate
|
||||||
|
// (the value is in the tooltip, and every number is in the table view).
|
||||||
|
const every = data.length <= 16 ? 1 : Math.ceil(data.length / 12);
|
||||||
|
return (
|
||||||
|
// The container includes the x-axis band — a fixed height that excludes it is
|
||||||
|
// how cards end up with a tiny nested scrollbar.
|
||||||
|
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||||
|
title={`${d.key} — ${nf(d.count)}`}>
|
||||||
|
<span className="text-[9px] text-muted-foreground mb-0.5 opacity-0 group-hover:opacity-100 transition-opacity tabular-nums whitespace-nowrap">
|
||||||
|
{nf(d.count)}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="w-full rounded-t-[4px] transition-[height] duration-300"
|
||||||
|
style={{ height: `${Math.max(2, (d.count / peak) * 100)}%`, background: 'var(--chart-1)' }}
|
||||||
|
/>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap overflow-visible">
|
||||||
|
{i % every === 0 ? d.key : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
||||||
|
// The categories (the operators) ARE the subject, so this is categorical colour,
|
||||||
|
// in the FIXED order the backend sends (busiest first). An operator therefore
|
||||||
|
// keeps the same hue everywhere on the page — a chart that repaints its series
|
||||||
|
// when the filter changes is a chart nobody can trust.
|
||||||
|
|
||||||
|
function RateStack({ rate, ops, byOp, empty, height = 130 }: {
|
||||||
|
rate: Bucket[]; ops: string[]; byOp: number[][]; empty: string; height?: number;
|
||||||
|
}) {
|
||||||
|
const [hov, setHov] = useState<number | null>(null);
|
||||||
|
if (rate.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
const peak = Math.max(1, ...rate.map((d) => d.count));
|
||||||
|
const every = rate.length <= 16 ? 1 : Math.ceil(rate.length / 12);
|
||||||
|
const single = ops.length <= 1; // one operator → one hue; a legend would be noise
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-end gap-[2px] min-w-0" style={{ height }}>
|
||||||
|
{rate.map((d, i) => (
|
||||||
|
<div key={d.key} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full"
|
||||||
|
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}>
|
||||||
|
{/* The column is the hour's total; the segments are who made it. */}
|
||||||
|
<div className="w-full flex flex-col-reverse justify-start rounded-t-[4px] overflow-hidden"
|
||||||
|
style={{ height: `${Math.max(1, (d.count / peak) * 100)}%` }}>
|
||||||
|
{(byOp[i] ?? []).map((n, o) => n > 0 && (
|
||||||
|
<div key={o} style={{
|
||||||
|
height: `${(n / Math.max(1, d.count)) * 100}%`,
|
||||||
|
background: single ? 'var(--chart-1)' : `var(--chart-${(o % 8) + 1})`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">
|
||||||
|
{i % every === 0 ? d.key : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover read-out: the hour, its total, and the split. Values are also in the
|
||||||
|
rate sheet below, so nothing is reachable by hover alone. */}
|
||||||
|
<p className="mt-1 h-4 text-[11px] text-muted-foreground tabular-nums truncate">
|
||||||
|
{hov !== null && (
|
||||||
|
<>
|
||||||
|
<span className="text-foreground font-medium">{rate[hov].key}</span>
|
||||||
|
{' · '}{nf(rate[hov].count)} QSO
|
||||||
|
{!single && (byOp[hov] ?? []).map((n, o) => n > 0 && (
|
||||||
|
<span key={o}> · <span style={{ color: `var(--chart-${(o % 8) + 1})` }}>■</span> {ops[o]} {n}</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!single && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1">
|
||||||
|
{ops.map((op, o) => (
|
||||||
|
<span key={op} className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||||
|
<span className="size-2.5 rounded-[3px]" style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||||||
|
{op}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The rate sheet: hour × operator, with the hour total ─────────────────────
|
||||||
|
// Exact numbers, many of them — that is a table's job, not a chart's. Contesters
|
||||||
|
// read rate sheets as tables, and every value here is also the one the chart draws.
|
||||||
|
|
||||||
|
function RateSheet({ rate, ops, byOp }: { rate: Bucket[]; ops: string[]; byOp: number[][] }) {
|
||||||
|
if (rate.length === 0) return null;
|
||||||
|
const shown = rate.map((d, i) => ({ d, i })).filter(({ d }) => d.count > 0); // silent hours add nothing here
|
||||||
|
const totals = ops.map((_, o) => rate.reduce((s, _d, i) => s + (byOp[i]?.[o] ?? 0), 0));
|
||||||
|
const grand = rate.reduce((s, d) => s + d.count, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto max-h-[260px] rounded-md border border-border">
|
||||||
|
<table className="w-full text-[11px] tabular-nums">
|
||||||
|
<thead className="sticky top-0 bg-muted/60 backdrop-blur">
|
||||||
|
<tr className="text-muted-foreground">
|
||||||
|
<th className="text-left font-medium px-2 py-1">UTC</th>
|
||||||
|
{ops.map((op, o) => (
|
||||||
|
<th key={op} className="text-right font-medium px-2 py-1 whitespace-nowrap">
|
||||||
|
<span className="inline-block size-2 rounded-[2px] mr-1 align-middle"
|
||||||
|
style={{ background: `var(--chart-${(o % 8) + 1})` }} />
|
||||||
|
{op}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="text-right font-semibold px-2 py-1 text-foreground">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shown.map(({ d, i }) => (
|
||||||
|
<tr key={d.key} className="border-t border-border/50">
|
||||||
|
<td className="px-2 py-0.5 text-muted-foreground whitespace-nowrap">{d.key}</td>
|
||||||
|
{ops.map((_, o) => (
|
||||||
|
<td key={o} className="px-2 py-0.5 text-right">
|
||||||
|
{byOp[i]?.[o] ? nf(byOp[i][o]) : <span className="text-muted-foreground/40">·</span>}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
<td className="px-2 py-0.5 text-right font-semibold">{nf(d.count)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="sticky bottom-0 bg-muted/60 backdrop-blur">
|
||||||
|
<tr className="border-t border-border font-semibold">
|
||||||
|
<td className="px-2 py-1">Total</td>
|
||||||
|
{totals.map((n, o) => <td key={o} className="px-2 py-1 text-right">{nf(n)}</td>)}
|
||||||
|
<td className="px-2 py-1 text-right">{nf(grand)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity over time (single series → area, one hue) ────────────────────────
|
||||||
|
// Only the PEAK and the LAST point are direct-labelled. A number on every point
|
||||||
|
// is chaos and goes unread.
|
||||||
|
|
||||||
|
function AreaTrend({ data, height = 160, empty }: { data: Bucket[]; height?: number; empty: string }) {
|
||||||
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
|
if (data.length < 2) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
|
||||||
|
const W = 1000, H = 100, peak = Math.max(1, ...data.map((d) => d.count));
|
||||||
|
const x = (i: number) => (i / (data.length - 1)) * W;
|
||||||
|
const y = (v: number) => H - (v / peak) * H;
|
||||||
|
const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.count).toFixed(1)}`).join(' ');
|
||||||
|
const area = `${line} L${W},${H} L0,${H} Z`;
|
||||||
|
const peakIdx = data.reduce((b, d, i) => (d.count > data[b].count ? i : b), 0);
|
||||||
|
const h = hover ?? -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="relative" style={{ height }}>
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-full overflow-visible"
|
||||||
|
onMouseLeave={() => setHover(null)}>
|
||||||
|
{/* Recessive hairline grid — solid, never dashed. */}
|
||||||
|
{[0, 0.5, 1].map((f) => (
|
||||||
|
<line key={f} x1={0} x2={W} y1={H * f} y2={H * f} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||||
|
))}
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="statsArea" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity="0.28" />
|
||||||
|
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity="0.02" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={area} fill="url(#statsArea)" />
|
||||||
|
<path d={line} fill="none" stroke="var(--chart-1)" strokeWidth={2} vectorEffect="non-scaling-stroke"
|
||||||
|
strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
{h >= 0 && (
|
||||||
|
<>
|
||||||
|
<line x1={x(h)} x2={x(h)} y1={0} y2={H} stroke="var(--muted-foreground)" strokeWidth={1} vectorEffect="non-scaling-stroke" opacity={0.5} />
|
||||||
|
{/* 2px surface ring so the marker reads on top of the area. */}
|
||||||
|
<circle cx={x(h)} cy={y(data[h].count)} r={4} fill="var(--chart-1)" stroke="var(--card)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Hit areas are far wider than the marks — pinpoint targets are unusable. */}
|
||||||
|
{data.map((_, i) => (
|
||||||
|
<rect key={i} x={x(i) - W / data.length / 2} y={0} width={W / data.length} height={H}
|
||||||
|
fill="transparent" onMouseEnter={() => setHover(i)} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{h >= 0 && (
|
||||||
|
<div className="pointer-events-none absolute -top-1 z-10 rounded-md border border-border bg-popover px-2 py-1 text-[11px] shadow-lg whitespace-nowrap"
|
||||||
|
style={{ left: `${(h / (data.length - 1)) * 100}%`, transform: 'translateX(-50%)' }}>
|
||||||
|
<span className="text-muted-foreground">{data[h].key}</span>{' '}
|
||||||
|
<span className="font-semibold tabular-nums">{nf(data[h].count)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-1.5 text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
<span>{data[0].key}</span>
|
||||||
|
<span className="text-foreground font-medium">↑ {data[peakIdx].key} · {nf(data[peakIdx].count)}</span>
|
||||||
|
<span>{data[data.length - 1].key}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Part-to-whole (continents) → the donut ───────────────────────────────────
|
||||||
|
// A pie/donut is legitimate for exactly this: part-to-whole, read at a glance,
|
||||||
|
// few segments, one share obviously dominant. It is the WRONG form for comparing
|
||||||
|
// close values (which is why modes stay bars — CW and SSB are nearly tied, and a
|
||||||
|
// donut would hide precisely the fact that makes them interesting).
|
||||||
|
//
|
||||||
|
// The categories ARE the subject here, so this is the one chart using the
|
||||||
|
// categorical slots — in FIXED order, with a legend and printed values, because
|
||||||
|
// the dark palette sits in the CVD floor band where labels are mandatory.
|
||||||
|
|
||||||
|
function Donut({ data, empty }: { data: Bucket[]; empty: string }) {
|
||||||
|
const [hov, setHov] = useState<number | null>(null);
|
||||||
|
const total = data.reduce((s, d) => s + d.count, 0);
|
||||||
|
if (total === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
|
||||||
|
const slots = data.slice(0, 8); // never generate a 9th hue
|
||||||
|
const R = 58, SW = 22, C = 2 * Math.PI * R;
|
||||||
|
let acc = 0;
|
||||||
|
const arcs = slots.map((d, i) => {
|
||||||
|
const frac = d.count / total;
|
||||||
|
// A 2px surface gap between segments — the correct separator, not a border.
|
||||||
|
const len = Math.max(0, frac * C - 2);
|
||||||
|
const a = { key: d.key, i, len, off: -acc * C, frac };
|
||||||
|
acc += frac;
|
||||||
|
return a;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 min-w-0">
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<svg viewBox="0 0 160 160" className="size-[140px] -rotate-90">
|
||||||
|
{arcs.map((a) => (
|
||||||
|
<circle key={a.key} cx={80} cy={80} r={R} fill="none"
|
||||||
|
stroke={`var(--chart-${a.i + 1})`} strokeWidth={hov === a.i ? SW + 4 : SW}
|
||||||
|
strokeDasharray={`${a.len} ${C - a.len}`} strokeDashoffset={a.off}
|
||||||
|
className="transition-[stroke-width] duration-150 cursor-default"
|
||||||
|
onMouseEnter={() => setHov(a.i)} onMouseLeave={() => setHov(null)} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{/* The hole is not decoration — it carries the total the slices add up to. */}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
{hov === null ? (
|
||||||
|
<>
|
||||||
|
<span className="text-[17px] font-semibold leading-none">{nf(total)}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground mt-0.5">QSO</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-[15px] font-semibold leading-none">{(arcs[hov].frac * 100).toFixed(1)}%</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground mt-0.5 truncate max-w-[92px] text-center">
|
||||||
|
{CONTINENT_NAME[slots[hov].key] ?? slots[hov].key}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="flex-1 min-w-0 flex flex-col gap-1">
|
||||||
|
{slots.map((d, i) => (
|
||||||
|
<li key={d.key}
|
||||||
|
onMouseEnter={() => setHov(i)} onMouseLeave={() => setHov(null)}
|
||||||
|
className={cn('flex items-center gap-1.5 min-w-0 text-[11px] rounded px-1 -mx-1',
|
||||||
|
hov === i && 'bg-muted')}>
|
||||||
|
{/* The swatch carries identity; the text stays in ink tokens. */}
|
||||||
|
<span className="size-2.5 rounded-[3px] shrink-0" style={{ background: `var(--chart-${i + 1})` }} />
|
||||||
|
<span className="truncate text-muted-foreground">{CONTINENT_NAME[d.key] ?? d.key}</span>
|
||||||
|
<span className="ml-auto shrink-0 tabular-nums text-foreground">{nf(d.count)}</span>
|
||||||
|
<span className="w-9 shrink-0 text-right tabular-nums text-muted-foreground">
|
||||||
|
{((d.count / total) * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── A single ratio against a limit → a meter, not a 2-slice pie ───────────────
|
||||||
|
|
||||||
|
function Meter({ label, value, total }: { label: string; value: number; total: number }) {
|
||||||
|
const pct = total > 0 ? (value / total) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-baseline justify-between gap-2 mb-1">
|
||||||
|
<span className="text-[11px] text-muted-foreground truncate">{label}</span>
|
||||||
|
<span className="text-[11px] tabular-nums">
|
||||||
|
<span className="font-semibold text-foreground">{pct.toFixed(1)}%</span>
|
||||||
|
<span className="text-muted-foreground"> · {nf(value)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Same-ramp track: the meter and its track are one hue, not two. */}
|
||||||
|
<div className="h-2 w-full rounded-full" style={{ background: 'var(--chart-seq-1)' }}>
|
||||||
|
<div className="h-full rounded-full transition-[width] duration-500"
|
||||||
|
style={{ width: `${Math.max(1, pct)}%`, background: 'var(--chart-1)' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Period ───────────────────────────────────────────────────────────────────
|
||||||
|
// ONE filter row above everything it scopes — never a control inside a card, or
|
||||||
|
// the charts would each show a different slice of time.
|
||||||
|
|
||||||
|
type Period = 'all' | 'ytd' | 'y12' | 'd30' | 'custom';
|
||||||
|
|
||||||
|
const iso = (d: Date) => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Resolve a preset to the [from, to] the backend expects. Empty = no bound.
|
||||||
|
function periodRange(p: Period, from: string, to: string): [string, string] {
|
||||||
|
const now = new Date();
|
||||||
|
switch (p) {
|
||||||
|
case 'ytd': return [`${now.getUTCFullYear()}-01-01`, ''];
|
||||||
|
case 'y12': {
|
||||||
|
const d = new Date(now); d.setUTCMonth(d.getUTCMonth() - 12);
|
||||||
|
return [iso(d), ''];
|
||||||
|
}
|
||||||
|
case 'd30': {
|
||||||
|
const d = new Date(now); d.setUTCDate(d.getUTCDate() - 30);
|
||||||
|
return [iso(d), ''];
|
||||||
|
}
|
||||||
|
case 'custom': return [from, to];
|
||||||
|
default: return ['', ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||||
|
const total = data.reduce((s, d) => s + d.count, 0);
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||||
|
<p className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground border-b border-border bg-muted/30">{title}</p>
|
||||||
|
<table className="w-full text-[11px]">
|
||||||
|
<tbody>
|
||||||
|
{data.map((d) => (
|
||||||
|
<tr key={d.key} className="border-b border-border/50 last:border-0">
|
||||||
|
<td className="px-3 py-1 truncate">{d.key}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums font-medium">{nf(d.count)}</td>
|
||||||
|
<td className="px-3 py-1 text-right tabular-nums text-muted-foreground w-14">
|
||||||
|
{total ? ((d.count / total) * 100).toFixed(1) + '%' : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{data.length === 0 && <tr><td className="px-3 py-2 text-muted-foreground italic">—</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Panel ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function StatsPanel() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
const [view, setView] = useState<'charts' | 'table'>('charts');
|
||||||
|
const [period, setPeriod] = useState<Period>('all');
|
||||||
|
const [from, setFrom] = useState('');
|
||||||
|
const [to, setTo] = useState('');
|
||||||
|
// Contest picker: "ID|YEAR", or '' for none. Selecting one narrows the log to
|
||||||
|
// that contest AND lets the window derive from its own span — no date typing.
|
||||||
|
const [runs, setRuns] = useState<ContestRun[]>([]);
|
||||||
|
const [contest, setContest] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { GetContestRuns().then((r: any) => setRuns((r ?? []) as ContestRun[])).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
const load = async (p: Period = period, f = from, t2 = to, c = contest) => {
|
||||||
|
// A contest defines its own window (its first→last QSO), so we send no dates
|
||||||
|
// with it — the backend derives them. Sending a period as well would be two
|
||||||
|
// filters fighting over the same axis.
|
||||||
|
const [cid, cyr] = c ? c.split('|') : ['', '0'];
|
||||||
|
const [a, b] = c ? ['', ''] : periodRange(p, f, t2);
|
||||||
|
setBusy(true); setErr('');
|
||||||
|
try {
|
||||||
|
const raw = (await GetLogStats(a, b, cid, parseInt(cyr, 10) || 0)) as any;
|
||||||
|
// Harden the boundary: a Go nil slice arrives as JSON null, and a single
|
||||||
|
// .length on null unmounts the whole React tree — a white screen. Normalise
|
||||||
|
// once, here, rather than guarding at every use site and missing one.
|
||||||
|
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||||
|
setStats({
|
||||||
|
...raw,
|
||||||
|
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
|
||||||
|
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||||
|
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||||
|
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||||
|
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||||
|
} as Stats);
|
||||||
|
}
|
||||||
|
catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
// Re-run whenever the filter changes. A custom range only fires once BOTH ends
|
||||||
|
// are set — otherwise every keystroke in the date box would re-scan the log.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contest && period === 'custom' && !(from && to)) return;
|
||||||
|
load(period, from, to, contest);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [period, from, to, contest]);
|
||||||
|
|
||||||
|
// The rate / off-air block belongs to a CONTEST-shaped effort, and nowhere else.
|
||||||
|
// Over a year it degenerates into nonsense — "4 156 h off air", a 94-hour "break"
|
||||||
|
// between two ordinary evenings — numbers that are true and completely useless.
|
||||||
|
// So: show it for a picked contest, or for a hand-set range short enough to be a
|
||||||
|
// real operating session (which is exactly when an hourly rate chart exists).
|
||||||
|
const windowed = contest !== '' || (period === 'custom' && (stats?.rate?.length ?? 0) > 1);
|
||||||
|
|
||||||
|
const span = useMemo(() => {
|
||||||
|
if (!stats?.first_qso) return '';
|
||||||
|
const f = new Date(stats.first_qso), l = new Date(stats.last_qso);
|
||||||
|
return `${f.toISOString().slice(0, 10)} → ${l.toISOString().slice(0, 10)}`;
|
||||||
|
}, [stats]);
|
||||||
|
|
||||||
|
if (busy && !stats) {
|
||||||
|
return <div className="flex-1 flex items-center justify-center text-muted-foreground gap-2">
|
||||||
|
<Loader2 className="size-4 animate-spin" /> <span className="text-xs">{t('stats.loading')}</span>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
if (err) return <div className="p-4 text-xs text-destructive">{err}</div>;
|
||||||
|
if (!stats) return null;
|
||||||
|
|
||||||
|
const empty = t('stats.noData');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto p-3">
|
||||||
|
{/* ONE filter/action row above everything it scopes — never per-card controls. */}
|
||||||
|
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||||
|
<h2 className="text-sm font-semibold">{t('stats.title')}</h2>
|
||||||
|
{span && <span className="text-[11px] text-muted-foreground tabular-nums">{span}</span>}
|
||||||
|
|
||||||
|
{/* A contest is picked from what's actually IN the log (CONTEST_ID + year),
|
||||||
|
never from a static list — so it can't offer a contest you never entered.
|
||||||
|
Choosing one supersedes the period: it brings its own window. */}
|
||||||
|
<select value={contest} onChange={(e) => setContest(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs max-w-[240px]">
|
||||||
|
<option value="">{t('stats.noContest')}</option>
|
||||||
|
{runs.map((r) => (
|
||||||
|
<option key={`${r.id}|${r.year}`} value={`${r.id}|${r.year}`}>
|
||||||
|
{r.id} {r.year} — {nf(r.count)} QSO
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className={cn('inline-flex rounded-md border border-border overflow-hidden', contest && 'opacity-40 pointer-events-none')}>
|
||||||
|
{([
|
||||||
|
['all', t('stats.pAll')], ['ytd', t('stats.pYTD')],
|
||||||
|
['y12', t('stats.p12m')], ['d30', t('stats.p30d')], ['custom', t('stats.pCustom')],
|
||||||
|
] as [Period, string][]).map(([p, label], i) => (
|
||||||
|
<button key={p} onClick={() => setPeriod(p)}
|
||||||
|
className={cn('px-2 h-7 text-xs whitespace-nowrap', i > 0 && 'border-l border-border',
|
||||||
|
period === p ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!contest && period === 'custom' && (
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||||
|
<span className="text-xs text-muted-foreground">→</span>
|
||||||
|
<input type="date" value={to} onChange={(e) => setTo(e.target.value)}
|
||||||
|
className="h-7 rounded-md border border-input bg-background px-1.5 text-xs" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
<button onClick={() => setView('charts')}
|
||||||
|
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs', view === 'charts' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||||
|
<BarChart3 className="size-3.5" />{t('stats.charts')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setView('table')}
|
||||||
|
className={cn('inline-flex items-center gap-1 px-2 h-7 text-xs border-l border-border', view === 'table' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted')}>
|
||||||
|
<Table2 className="size-3.5" />{t('stats.table')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => load()} disabled={busy} title={t('stats.refresh')}
|
||||||
|
className="inline-flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-50">
|
||||||
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Headline figures: stat tiles, not a grouped bar chart. */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2.5 mb-3">
|
||||||
|
<StatTile label={t('stats.qsos')} value={nf(stats.total)} sub={span} />
|
||||||
|
<StatTile label={t('stats.uniqueCalls')} value={nf(stats.unique_calls)} />
|
||||||
|
<StatTile label={t('stats.entities')} value={nf(stats.entities)} sub="DXCC" />
|
||||||
|
<StatTile label={t('stats.continents')} value={nf(stats.continents)} sub="/ 7" />
|
||||||
|
<StatTile label={t('stats.confirmed')} value={`${stats.total ? ((stats.confirmed_any / stats.total) * 100).toFixed(0) : 0}%`}
|
||||||
|
sub={`${nf(stats.confirmed_any)} / ${nf(stats.total)}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period / contest block — ONLY when a window is selected. "12 QSO/h" across
|
||||||
|
seventeen years is noise; across a contest weekend it IS the result. */}
|
||||||
|
{windowed && stats.total > 0 && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5 mb-3">
|
||||||
|
<Card title={t('stats.rate')} sub={t('stats.rateSub')} className="lg:col-span-2">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-3">
|
||||||
|
{/* Two rates on purpose: one honest, one flattering — see the tooltip. */}
|
||||||
|
<div title={t('stats.avgWindowTip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgWindow')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_hour.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||||
|
</div>
|
||||||
|
<div title={t('stats.avgActiveTip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.avgActive')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">{stats.avg_per_active.toFixed(1)}<span className="text-[11px] text-muted-foreground font-normal"> /h</span></p>
|
||||||
|
</div>
|
||||||
|
<div title={t('stats.best60Tip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.best60')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">{nf(stats.best_60)}</p>
|
||||||
|
</div>
|
||||||
|
{/* On-air + off-air = the window, by construction. The first version
|
||||||
|
counted "clock hours containing a QSO", which gave 39 h on air AND
|
||||||
|
16 h off air inside a 45 h contest — and rightly wasn't believed. */}
|
||||||
|
<div title={t('stats.onAirTip')}>
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-muted-foreground truncate">{t('stats.activeHours')}</p>
|
||||||
|
<p className="text-[22px] leading-none font-semibold mt-1">
|
||||||
|
{dur(stats.on_air_minutes)}
|
||||||
|
<span className="text-[11px] text-muted-foreground font-normal"> / {Math.round(stats.window_hours)} h</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{stats.rate.length > 1
|
||||||
|
? <RateStack rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} empty={empty} />
|
||||||
|
: <p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.rateTooLong')}</p>}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('stats.offAir')} sub={t('stats.offAirSub', { d: dur(stats.off_air_minutes) })}>
|
||||||
|
{stats.gaps.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-muted-foreground italic py-3 text-center">{t('stats.noGaps')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1 min-w-0">
|
||||||
|
{stats.gaps.map((g, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2 text-[11px] min-w-0">
|
||||||
|
<span className="tabular-nums text-muted-foreground truncate">
|
||||||
|
{g.start.slice(5, 16).replace('T', ' ')} → {g.end.slice(11, 16)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 h-[6px] rounded-full min-w-0"
|
||||||
|
style={{
|
||||||
|
background: 'var(--chart-seq-1)',
|
||||||
|
}}>
|
||||||
|
<span className="block h-full rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, (g.minutes / Math.max(1, stats.gaps[0].minutes)) * 100)}%`,
|
||||||
|
background: 'var(--chart-1)',
|
||||||
|
}} />
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums font-medium">{dur(g.minutes)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* The rate sheet: exact numbers, many of them, hour by hour and operator
|
||||||
|
by operator. That is a table's job, not a chart's — and it's how
|
||||||
|
contesters actually read a run. Every value here is also what the
|
||||||
|
stacked chart above draws, so the two can never disagree. */}
|
||||||
|
{stats.rate.length > 1 && (
|
||||||
|
<Card title={t('stats.rateSheet')} sub={t('stats.rateSheetSub')} className="lg:col-span-3">
|
||||||
|
<RateSheet rate={stats.rate} ops={stats.rate_ops} byOp={stats.rate_by_op} />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'table' ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-2.5">
|
||||||
|
<BucketTable title={t('stats.byBand')} data={stats.by_band} />
|
||||||
|
<BucketTable title={t('stats.byMode')} data={stats.by_mode} />
|
||||||
|
<BucketTable title={t('stats.byOperator')} data={stats.by_operator} />
|
||||||
|
<BucketTable title={t('stats.byContinent')} data={stats.by_continent} />
|
||||||
|
<BucketTable title={t('stats.topEntities')} data={stats.top_entities} />
|
||||||
|
<BucketTable title={t('stats.byYear')} data={stats.by_year} />
|
||||||
|
<BucketTable title={t('stats.byStation')} data={stats.by_station} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||||
|
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')}>
|
||||||
|
<VBars data={stats.by_band} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
<Card title={t('stats.byMode')}>
|
||||||
|
<HBars data={stats.by_mode} max={8} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2">
|
||||||
|
<AreaTrend data={stats.by_month} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||||
|
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||||
|
instead of truncating. */}
|
||||||
|
<Card title={t('stats.byOperator')} sub={t('stats.byOperatorSub')}>
|
||||||
|
<div className="max-h-[240px] overflow-auto pr-1 min-w-0">
|
||||||
|
<HBars data={stats.by_operator} empty={empty} share />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card title={t('stats.byContinent')} sub={t('stats.byContinentSub')}>
|
||||||
|
<Donut data={stats.by_continent} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title={t('stats.topEntities')}>
|
||||||
|
<HBars data={stats.top_entities} max={12} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
<div className="flex flex-col gap-2.5 min-w-0">
|
||||||
|
<Card title={t('stats.confirmations')} className="flex-1">
|
||||||
|
<div className="flex flex-col gap-3 justify-center flex-1">
|
||||||
|
<Meter label="LoTW" value={stats.confirmed_lotw} total={stats.total} />
|
||||||
|
<Meter label="eQSL" value={stats.confirmed_eqsl} total={stats.total} />
|
||||||
|
<Meter label={t('stats.paperQSL')} value={stats.confirmed_qsl} total={stats.total} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card title={t('stats.byStation')}>
|
||||||
|
<div className="max-h-[140px] overflow-auto pr-1 min-w-0">
|
||||||
|
<HBars data={stats.by_station} empty={empty} share />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -321,7 +321,6 @@ function EditDialog({
|
|||||||
<Label>{t('udpp.name')}</Label>
|
<Label>{t('udpp.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder={draft.direction === 'inbound' ? t('udpp.namePhInbound') : t('udpp.namePhOutbound')}
|
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+140
-18
@@ -13,6 +13,8 @@ type Dict = Record<string, string>;
|
|||||||
|
|
||||||
const en: Dict = {
|
const en: Dict = {
|
||||||
// Menu bar
|
// Menu bar
|
||||||
|
'prop.title': 'Propagation', 'prop.geomag': 'Geomag', 'prop.refresh': 'Refresh space weather',
|
||||||
|
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
||||||
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
||||||
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
|
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
|
||||||
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
|
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
|
||||||
@@ -37,15 +39,49 @@ const en: Dict = {
|
|||||||
'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid',
|
'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid',
|
||||||
'field.band': 'Band', 'field.mode': 'Mode', 'field.country': 'Country', 'field.comment': 'Comment',
|
'field.band': 'Band', 'field.mode': 'Mode', 'field.country': 'Country', 'field.comment': 'Comment',
|
||||||
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
|
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
|
||||||
'field.txFreq': 'TX Freq (MHz)', 'field.freq': 'Freq (MHz)', 'field.rxFreq': 'RX Freq (MHz)', 'field.rxBand': 'RX Band',
|
'field.txFreq': 'TX Freq (MHz)', 'field.freqTuneHint': 'Type a frequency and press Enter to tune the radio here.', 'field.freq': 'Freq (MHz)', 'field.rxFreq': 'RX Freq (MHz)', 'field.rxBand': 'RX Band',
|
||||||
'field.startUtc': 'Start UTC', 'field.endUtc': 'End UTC', 'field.snt': 'Snt', 'field.rcv': 'Rcv',
|
'field.startUtc': 'Start UTC', 'field.endUtc': 'End UTC', 'field.snt': 'Snt', 'field.rcv': 'Rcv',
|
||||||
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
|
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
|
||||||
// Language chooser
|
// Language chooser
|
||||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français',
|
'lang.english': 'English', 'lang.french': 'Français',
|
||||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
||||||
|
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
||||||
|
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
||||||
|
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
||||||
|
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmed',
|
||||||
|
'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size',
|
||||||
|
'stats.byMode': 'By mode', 'stats.byOperator': 'By operator',
|
||||||
|
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
|
||||||
|
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
|
||||||
|
'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year',
|
||||||
|
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'QSOs per month',
|
||||||
|
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL',
|
||||||
|
|
||||||
|
'stats.byContinentSub': 'Share of the log',
|
||||||
|
'stats.pAll': 'All', 'stats.pYTD': 'This year', 'stats.p12m': '12 months', 'stats.p30d': '30 days', 'stats.pCustom': 'Custom',
|
||||||
|
'stats.rate': 'Rate', 'stats.rateSub': 'QSOs per hour across the period — silences included',
|
||||||
|
'stats.rateTooLong': 'Period too long for an hourly rate chart (max 31 days)',
|
||||||
|
'stats.avgWindow': 'Avg / hour', 'stats.avgWindowTip': 'QSOs ÷ the WHOLE period, breaks included. The honest rate.',
|
||||||
|
'stats.avgActive': 'Avg / hour on air', 'stats.avgActiveTip': 'QSOs ÷ the hours you actually operated. Flattering — quoting only this is how an 8-hour effort gets sold as a 48-hour score.',
|
||||||
|
'stats.best60': 'Best 60 min', 'stats.best60Tip': 'Best ROLLING 60 minutes (not the best clock hour) — the figure contesters quote.',
|
||||||
|
'stats.activeHours': 'Time on air', 'stats.onAirTip': 'Window minus every silence of 30 min or more. On-air + off-air = the window, by construction.',
|
||||||
|
'stats.offAir': 'Off air', 'stats.offAirSub': 'Silences ≥ 30 min — {d} total',
|
||||||
|
'stats.noGaps': 'No break of 30 min or more.', 'stats.rateSheet': 'Rate sheet', 'stats.rateSheetSub': 'Hour by hour — who made the QSOs (silent hours omitted)', 'stats.noContest': '— No contest —',
|
||||||
|
'cluster.console': 'Console', 'cluster.clear': 'Clear', 'cluster.hideConsole': 'Hide console', 'cluster.showConsole': 'Show the raw cluster console',
|
||||||
|
'cluster.consoleEmpty': 'Raw cluster traffic appears here — including the answers to your commands (SH/DX, WHO, …).',
|
||||||
|
'msg.expand': 'Click to read the full message',
|
||||||
|
'offline.queued': 'Database unreachable — QSO saved locally, will sync automatically',
|
||||||
|
'offline.tip': '{n} QSO(s) waiting — the database is unreachable',
|
||||||
|
'offline.title': 'Offline — {n} QSO(s) waiting',
|
||||||
|
'offline.explain': "Saved to a local file, nothing is lost. They'll be added to the logbook automatically once the database answers. Note: worked-before does NOT include them.",
|
||||||
|
'offline.empty': 'Nothing waiting.',
|
||||||
|
'offline.retry': 'Retry now',
|
||||||
|
'offline.synced': '{n} QSO(s) added to the logbook',
|
||||||
|
'offline.stillDown': 'Database still unreachable — your QSOs are safe',
|
||||||
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
||||||
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.dark-warm': 'Warm dark',
|
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
|
||||||
|
'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
|
||||||
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
|
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
|
||||||
// Settings navigation (sidebar groups + section names)
|
// Settings navigation (sidebar groups + section names)
|
||||||
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
|
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
|
||||||
@@ -55,7 +91,7 @@ const en: Dict = {
|
|||||||
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
||||||
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup',
|
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup',
|
||||||
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'CW Keyer',
|
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'CW Keyer',
|
||||||
'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||||
// General panel
|
// General panel
|
||||||
'gen.hint': 'App behaviour (saved instantly).',
|
'gen.hint': 'App behaviour (saved instantly).',
|
||||||
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
|
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
|
||||||
@@ -129,13 +165,15 @@ const en: Dict = {
|
|||||||
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||||
'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||||
'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||||
// CAT panel body
|
// CAT panel body
|
||||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||||
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
||||||
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
|
||||||
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)",
|
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
|
||||||
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
|
'cat.icomNetAudioHint': 'Play the rig’s received audio through your Listening device (Settings → Audio) over the 50003 stream. Experimental — the audio framing is pending on-rig verification; leave off if control misbehaves.',
|
||||||
|
'cat.omnirigRig': 'OmniRig rig slot', 'cat.flexIp': 'FlexRadio IP', 'cat.port': 'Port', 'cat.flexSpots': 'Show cluster spots on the panadapter', 'cat.flexSpotsHint': "(spots from OpsLog's DX cluster appear on the radio, auto-expire after 30 min)", 'cat.flexDecodeSpots': 'Show WSJT-X decodes on the panadapter', 'cat.flexDecodeSpotsHint': '(heard FT8/FT4 stations from your WSJT-X/JTDX UDP feed, one spot per call)', 'cat.flexDecodeSecs': 'Display for', 'cat.flexDecodeSecsHint': 'seconds before a station is removed',
|
||||||
|
'cat.icomPort': 'Icom CI-V port', 'cat.selectCom': 'Select COM port', 'cat.noPorts': 'No ports found', 'cat.baud': 'Baud rate', 'cat.icomModel': 'Rig model', 'cat.icomModelOther': 'Other (custom address)', 'cat.civAddr': 'CI-V address (hex)', 'cat.civHint': 'Pick your model to set the CI-V address automatically (or choose "Other" and type it). Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.',
|
||||||
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
|
'cat.tciHost': 'TCI host', 'cat.tciHint': 'Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.', 'cat.tciSpots': 'Show cluster spots on the panorama', 'cat.tciSpotsHint': "(spots from OpsLog's DX cluster appear on the SDR panadapter)",
|
||||||
'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)',
|
'cat.pollMs': 'Poll interval (ms)', 'cat.delayMs': 'CAT delay (ms)', 'cat.digitalDefault': 'Default digital mode (when rig reports DIG)', 'cat.modeBeforeFreq': 'Set mode before frequency', 'cat.modeBeforeFreqHint': '(older rigs that drop the mode after a band change)',
|
||||||
'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).',
|
'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).',
|
||||||
@@ -144,6 +182,7 @@ const en: Dict = {
|
|||||||
'cat.ubOk': 'Connected — the Ultrabeam responded with a status frame.',
|
'cat.ubOk': 'Connected — the Ultrabeam responded with a status frame.',
|
||||||
// External services (repeated labels)
|
// External services (repeated labels)
|
||||||
'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.apiKey': 'API key', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
|
'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.apiKey': 'API key', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
|
||||||
|
'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 1–4 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.',
|
||||||
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
|
'hw.connecting': 'Connecting…', 'hw.testConn': 'Test connection', 'hw.sending': 'Sending…', 'hw.testRotator': 'Test (point to 0°)',
|
||||||
// Profiles panel
|
// Profiles panel
|
||||||
'prof.deleteConfirm': 'Delete profile "{name}"? All its settings will be lost.', 'prof.dupPrompt': 'Name for the new profile (copy of "{name}"):', 'prof.dupSuffix': '{name} Copy', 'prof.newPrompt': 'Name for the new profile:', 'prof.newDefault': 'New profile',
|
'prof.deleteConfirm': 'Delete profile "{name}"? All its settings will be lost.', 'prof.dupPrompt': 'Name for the new profile (copy of "{name}"):', 'prof.dupSuffix': '{name} Copy', 'prof.newPrompt': 'Name for the new profile:', 'prof.newDefault': 'New profile',
|
||||||
@@ -191,9 +230,10 @@ const en: Dict = {
|
|||||||
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
|
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
|
||||||
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
|
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
|
||||||
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
|
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
|
||||||
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.',
|
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
|
||||||
|
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
|
||||||
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
|
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
|
||||||
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
|
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
|
||||||
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
|
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
|
||||||
'rst.clickToFill': 'Click to set RST tx from the signal',
|
'rst.clickToFill': 'Click to set RST tx from the signal',
|
||||||
'qrz.openTitle': 'Open {call} on QRZ.com',
|
'qrz.openTitle': 'Open {call} on QRZ.com',
|
||||||
@@ -208,7 +248,28 @@ const en: Dict = {
|
|||||||
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
'awrp.remove': 'Remove', 'awrp.searchLabel': 'Search {label}…', 'awrp.searching': 'Searching…', 'awrp.noMatch': 'No match.', 'awrp.noMatchDxcc': 'No match for this DXCC.',
|
||||||
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
'awrs.group': 'Group', 'awrs.sub': 'Sub', 'awrs.pickReference': '← pick a reference', 'awrs.add': 'Add', 'awrs.enterCallsignFirst': 'Enter a callsign first', 'awrs.noRefsAdded': 'No references added yet', 'awrs.references': 'References', 'awrs.autoMatchTitle': 'The {field} field is {code} — this award counts it automatically', 'awrs.fromField': 'from {field}', 'awrs.autoClickToAdd': 'auto — click to add', 'awrs.search': 'Search…', 'awrs.addUnlistedTitle': "Add this reference even though it isn't in the list yet (new / unlisted)", 'awrs.addPrefix': '+ Add', 'awrs.unlisted': '(unlisted)', 'awrs.searching': 'Searching…', 'awrs.typeToSearch': 'Type 2+ chars to search', 'awrs.enterCallsignOrSearch': 'Enter a callsign, or type to search.', 'awrs.noRefsForEntity': 'No references for this entity.', 'awrs.noResults': 'No results.', 'awrs.downloadLists': 'Download reference lists in the Awards panel → Import data.',
|
||||||
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
'awp.awards': 'Awards', 'awp.editAwards': 'Edit awards', 'awp.rescanTitle': 'Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Select an award…', 'awp.of': 'of', 'awp.computing': 'Computing…', 'awp.noData': 'No data', 'awp.worked': 'worked', 'awp.confirmed': 'confirmed', 'awp.validated': 'validated', 'awp.ofConfirmed': 'of {total} · {pct}% confirmed', 'awp.byBand': 'By band (confirmed / worked)', 'awp.filterReferences': 'Filter references…', 'awp.filterAll': 'All', 'awp.filterWkd': 'Wkd', 'awp.filterNotWkd': 'Not wkd', 'awp.filterWkdNotCfmd': 'Wkd not cfmd', 'awp.refs': 'refs', 'awp.missingRefsTitle': "Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it", 'awp.missingRefs': 'Missing refs', 'awp.gridView': 'Grid view', 'awp.listView': 'List view', 'awp.statistics': 'Statistics', 'awp.statistic': 'Statistic', 'awp.total': 'Total', 'awp.grand': 'Grand', 'awp.ref': 'Ref', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — click to view QSOs', 'awp.name': 'Name', 'awp.groupCol': 'Group', 'awp.status': 'Status', 'awp.bands': 'Bands', 'awp.missing': '— missing', 'awp.contactsMissingRef': 'contacts missing a reference', 'awp.recomputeTitle': "Recompute now — contacts you've fixed drop off the list", 'awp.refresh': 'Refresh', 'awp.missingScopeHelp': "In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet. Sort by a column, tick the matching contacts, then assign the reference below.", 'awp.orClickRow': '(Or click a row to open the QSO.)', 'awp.selectedArrow': '{n} selected →', 'awp.chooseReference': 'Choose a reference to assign…', 'awp.assignToSelected': 'Assign to {n} selected', 'awp.scanning': 'Scanning…', 'awp.noGaps': 'No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity — e.g. DDFM, WAS, RAC, WAJA.)', 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Callsign', 'awp.band': 'Band', 'awp.mode': 'Mode', 'awp.country': 'Country', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts without a reference', 'awp.assignedMsg': 'Assigned {code}@{ref} to {n} contact(s).', 'awp.loading': 'Loading…', 'awp.noQsos': 'No QSOs.',
|
||||||
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.allowMultiple': 'Allow multiple references on a single QSO', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Additional searches', 'awed.orAlsoMatch': '— also match the reference if any of these hit', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.grantCodes': 'Grant codes', 'awed.exportCreditGranted': 'Export award in ADIF credit_granted field', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
'awed.addCountry': 'Add country…', 'awed.exportedTo': 'Awards exported to:\n{path}', 'awed.importedMsg': 'Imported {awards} award(s) and {references} reference(s).', 'awed.awardManagement': 'Award management', 'awed.searchAwards': 'Search awards…', 'awed.newAward': 'New award', 'awed.clickToDismiss': 'Click to dismiss', 'awed.selectOrCreate': 'Select or create an award.', 'awed.tabInfo': 'Award info', 'awed.tabType': 'Award type', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'References', 'awed.awardName': 'Award name', 'awed.valid': 'Valid', 'awed.deleteAward': 'Delete award', 'awed.description': 'Description', 'awed.awardUrl': 'Award URL', 'awed.refDisplay': 'Column shows', 'awed.refDisplayRef': 'Reference', 'awed.refDisplayName': 'Description / name', 'awed.refDisplayBoth': 'Both (ref — name)', 'awed.referenceUrl': 'Reference URL', 'awed.validFrom': 'Valid from', 'awed.validTo': 'Valid to', 'awed.dxccFilter': 'DXCC filter', 'awed.validBands': 'Valid bands (empty = all)', 'awed.emission': 'Emission (empty = all)', 'awed.validModes': 'Valid modes (empty = all)', 'awed.awardType': 'Award type', 'awed.dynamicRefs': 'Dynamic references (not predefined — any value counts, like POTA)', 'awed.qsoParams': 'QSO parameters (used by QSOFIELDS / REFERENCE types)', 'awed.searchInField': 'Search in field', 'awed.matchBy': 'Match by', 'awed.exactMatch': 'Exact match (else search reference inside the field)', 'awed.patternRegex': 'Pattern (regex)', 'awed.patternPlaceholder': 'group 1 = reference (for match-by pattern / dynamic)', 'awed.leadingString': 'Leading string', 'awed.trailingString': 'Trailing string', 'awed.additionalSearches': 'Fallback searches', 'awed.orAlsoMatch': '— tried in order, only if nothing matched yet; first hit wins', 'awed.addOr': 'Add OR', 'awed.orSearchIn': 'OR — search in', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Remove this OR search', 'awed.orPatternPlaceholder': 'regex — group 1 = reference (e.g. \\b(\\d{2})\\d{3}\\b for postal → dept)', 'awed.prefixPlaceholder': 'prefix (D)', 'awed.prefixTitle': 'Prepended to each found reference, e.g. 74 → D74', 'awed.confirmationLabel': 'Confirmation (worked → confirmed)', 'awed.validationLabel': 'Validation (confirmed → validated)', 'awed.resetDefaults': 'Reset to defaults', 'awed.exportTitle': 'Export all award definitions + reference lists to a JSON backup', 'awed.export': 'Export…', 'awed.importTitle': 'Import an award bundle (definitions + reference lists)', 'awed.import': 'Import…', 'awed.cancel': 'Cancel', 'awed.save': 'Save', 'awed.populatedMsg': 'Populated {n} built-in references.', 'awed.newRefCodePrompt': 'New reference code:', 'awed.importedRefsMsg': 'Imported {n} references.', 'awed.referenceCount': 'Reference count:', 'awed.applyPreset': 'Apply preset…', 'awed.pasteCsv': 'Paste / CSV', 'awed.populateBuiltinTitle': 'Replace with the shipped built-in list (DXCC entities, French departments, …)', 'awed.populateBuiltin': 'Populate built-in', 'awed.updateOnline': 'Update online', 'awed.add': 'Add', 'awed.onePerLine': 'One reference per line:', 'awed.replacesList': '(comma/semicolon/tab). Replaces the whole list.', 'awed.import2': 'Import', 'awed.search': 'Search…', 'awed.searching': 'Searching…', 'awed.tooManyItems': 'Too many items ({total}). Please refine search (type 2+ characters).', 'awed.noReferences': 'No references.', 'awed.selectReference': 'Select a reference, or Add / import a list.', 'awed.group': 'Group', 'awed.subgroup': 'Subgroup', 'awed.perRefRegex': 'optional per-reference regex', 'awed.grid': 'Grid', 'awed.saveReference': 'Save reference',
|
||||||
|
'awed.updateAvailable': 'An updated version of this award is available', 'awed.updateOverwrites': 'You have modified this award, so the update was not applied. Taking it replaces your definition and reference list.', 'awed.updateApply': 'Update', 'awed.updateKeepMine': 'Keep mine',
|
||||||
|
'awed.tabTest': 'Test', 'awed.testCallsign': 'Test against callsign', 'awed.testRun': 'Test', 'awed.testSavedOnly': 'Tests the SAVED award — save your changes first.', 'awed.testNoMatch': 'no match', 'awed.testOutOfScope': 'QSO out of scope — no rule was run.', 'awed.testSkipped': 'not run: an earlier rule already matched', 'awed.testFieldValue': 'Field', 'awed.testEmptyField': 'empty', 'awed.testNoCandidate': 'produced no candidate', 'awed.testManual': 'Manual override', 'awed.testSameAs': '+{n} other QSO(s), same result',
|
||||||
|
'awed.exportOne': 'Share {code}',
|
||||||
|
'awed.onlyHere': 'local',
|
||||||
|
'awed.onlyHereTip': 'Yours — not shipped with OpsLog. Its JSON is kept up to date in the awards folder; send that file to share it.',
|
||||||
|
'awed.builtin': 'Built-in',
|
||||||
|
'awed.awardsFolder': 'Awards folder',
|
||||||
|
'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.',
|
||||||
|
'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.',
|
||||||
|
'awed.protectedFlag': 'Protected',
|
||||||
|
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
|
||||||
|
'awed.exportOneTitle': 'Export THIS award on its own (definition + references + their regexes) — the file you send someone',
|
||||||
|
'awed.importCollisionTitle': 'Some of these awards already exist',
|
||||||
|
'awed.importCollisionHint': 'Nothing is replaced unless you say so. Import as a copy installs theirs alongside yours, so you can compare before deleting one.',
|
||||||
|
'awed.importRefs': '{n} reference(s)',
|
||||||
|
'awed.importNew': 'New — will be added.',
|
||||||
|
'awed.importExists': 'You already have "{name}" with {n} reference(s).',
|
||||||
|
'awed.importProtected': 'built-in',
|
||||||
|
'awed.importKeepMine': 'Keep mine',
|
||||||
|
'awed.importReplace': 'Replace mine',
|
||||||
|
'awed.importCopy': 'Import as {code}-2',
|
||||||
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
// QSO modals (context menu / bulk edit / QSL manager / QSO edit)
|
||||||
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
'qctx.selected': '{n} QSO(s) selected', 'qctx.fixCountry': 'Fix country & zones from cty.dat', 'qctx.updateQrz': 'Update from QRZ.com', 'qctx.updateClublog': 'Update from ClubLog (exceptions)', 'qctx.sendQslEmail': 'Send OpsLog QSL by e-mail', 'qctx.sendRecording': 'Send recording by e-mail', 'qctx.bulkEdit': 'Bulk edit field… ({n})', 'qctx.exportSelectedAdif': 'Export selected to ADIF ({n})', 'qctx.exportFilteredAdif': 'Export filtered view to ADIF (no limit)', 'qctx.exportSelectedCabrillo': 'Export selected to Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Export filtered view to Cabrillo (no limit)', 'qctx.sendTo': 'Send to {name}', 'qctx.delete': 'Delete {n} QSO(s)…',
|
||||||
'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fContestId': 'Contest ID', 'bulk.fSrxString': 'Serial rcvd (exchange)', 'bulk.fStxString': 'Serial sent (exchange)', 'bulk.fArrlSect': 'ARRL section', 'bulk.fPrecedence': 'Precedence', 'bulk.fClass': 'Class', 'bulk.fPropMode': 'Propagation mode', 'bulk.fSatName': 'Satellite name', 'bulk.fSatMode': 'Satellite mode', 'bulk.fPotaRef': 'POTA ref', 'bulk.fSotaRef': 'SOTA ref', 'bulk.fWwffRef': 'WWFF ref', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupContacted': 'Contacted station', 'bulk.groupContest': 'Contest', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}',
|
'bulk.fLotwSent': 'LoTW sent', 'bulk.fLotwRcvd': 'LoTW received', 'bulk.fEqslSent': 'eQSL sent', 'bulk.fEqslRcvd': 'eQSL received', 'bulk.fQslSent': 'Paper QSL sent', 'bulk.fQslRcvd': 'Paper QSL received', 'bulk.fQrzUpload': 'QRZ.com upload', 'bulk.fClublogUpload': 'Club Log upload', 'bulk.fHrdlogUpload': 'HRDLog upload', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Station callsign', 'bulk.fOperator': 'Operator', 'bulk.fMyGrid': 'My grid', 'bulk.fMyAntenna': 'My antenna', 'bulk.fMyRig': 'My rig', 'bulk.fMyStreet': 'My street', 'bulk.fMyCity': 'My city', 'bulk.fMyPostal': 'My postal code', 'bulk.fMyCountry': 'My country', 'bulk.fMyState': 'My state', 'bulk.fMyCounty': 'My county', 'bulk.fMyIota': 'My IOTA', 'bulk.fMySota': 'My SOTA ref', 'bulk.fMyPota': 'My POTA ref', 'bulk.fMyWwff': 'My WWFF ref', 'bulk.fMySig': 'My SIG', 'bulk.fMySigInfo': 'My SIG info', 'bulk.fContestId': 'Contest ID', 'bulk.fSrxString': 'Serial rcvd (exchange)', 'bulk.fStxString': 'Serial sent (exchange)', 'bulk.fArrlSect': 'ARRL section', 'bulk.fPrecedence': 'Precedence', 'bulk.fClass': 'Class', 'bulk.fPropMode': 'Propagation mode', 'bulk.fSatName': 'Satellite name', 'bulk.fSatMode': 'Satellite mode', 'bulk.fPotaRef': 'POTA ref', 'bulk.fSotaRef': 'SOTA ref', 'bulk.fWwffRef': 'WWFF ref', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'SIG info', 'bulk.fComment': 'Comment', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Rig (contacted)', 'bulk.fAnt': 'Antenna (contacted)', 'bulk.statusY': 'Y — Yes / uploaded', 'bulk.statusN': 'N — No', 'bulk.statusR': 'R — Requested', 'bulk.statusI': 'I — Ignore', 'bulk.statusBlank': '(blank — clear)', 'bulk.groupQsl': 'QSL / upload', 'bulk.groupMyStation': 'My station', 'bulk.groupContacted': 'Contacted station', 'bulk.groupContest': 'Contest', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Misc', 'bulk.title': 'Bulk edit field', 'bulk.desc': 'Set one field on the {n} selected QSO(s). This overwrites the current value — there is no undo.', 'bulk.fieldLabel': 'Field', 'bulk.valueLabel': 'Value', 'bulk.clearPlaceholder': 'leave empty to clear the field', 'bulk.willSet': 'Will set', 'bulk.blank': '(blank)', 'bulk.onQsos': 'on {n} QSO(s).', 'bulk.cancel': 'Cancel', 'bulk.applyTo': 'Apply to {n}',
|
||||||
@@ -221,6 +282,8 @@ const en: Dict = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fr: Dict = {
|
const fr: Dict = {
|
||||||
|
'prop.title': 'Propagation', 'prop.geomag': 'Géomag', 'prop.refresh': 'Actualiser la météo spatiale',
|
||||||
|
'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
|
||||||
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
|
||||||
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
|
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
|
||||||
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
|
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
|
||||||
@@ -242,14 +305,48 @@ const fr: Dict = {
|
|||||||
'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator',
|
'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator',
|
||||||
'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire',
|
'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire',
|
||||||
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
|
'field.note': 'Note', 'field.rstTx': 'RST tx', 'field.rstRx': 'RST rx',
|
||||||
'field.txFreq': 'Fréq TX (MHz)', 'field.freq': 'Fréq (MHz)', 'field.rxFreq': 'Fréq RX (MHz)', 'field.rxBand': 'Bande RX',
|
'field.txFreq': 'Fréq TX (MHz)', 'field.freqTuneHint': 'Tape une fréquence et appuie sur Entrée pour y accorder la radio.', 'field.freq': 'Fréq (MHz)', 'field.rxFreq': 'Fréq RX (MHz)', 'field.rxBand': 'Bande RX',
|
||||||
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
|
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
|
||||||
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
|
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
|
||||||
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français',
|
'lang.english': 'English', 'lang.french': 'Français',
|
||||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
||||||
|
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
|
||||||
|
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
||||||
|
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
||||||
|
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmés',
|
||||||
|
'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille",
|
||||||
|
'stats.byMode': 'Par mode', 'stats.byOperator': 'Par opérateur',
|
||||||
|
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas d’OPERATOR renseigné)',
|
||||||
|
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
|
||||||
|
'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année',
|
||||||
|
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'QSO par mois',
|
||||||
|
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
|
||||||
|
|
||||||
|
'stats.byContinentSub': 'Part du journal',
|
||||||
|
'stats.pAll': 'Tout', 'stats.pYTD': 'Cette année', 'stats.p12m': '12 mois', 'stats.p30d': '30 jours', 'stats.pCustom': 'Personnalisé',
|
||||||
|
'stats.rate': 'Cadence', 'stats.rateSub': 'QSO par heure sur la période — silences compris',
|
||||||
|
'stats.rateTooLong': 'Période trop longue pour une courbe horaire (max 31 jours)',
|
||||||
|
'stats.avgWindow': 'Moy. / heure', 'stats.avgWindowTip': 'QSO ÷ la période ENTIÈRE, pauses comprises. La vraie cadence.',
|
||||||
|
'stats.avgActive': 'Moy. / heure en l’air', 'stats.avgActiveTip': 'QSO ÷ les heures réellement opérées. Flatteur — ne citer que celle-ci, c’est vendre 8 h d’effort comme un score de 48 h.',
|
||||||
|
'stats.best60': 'Meilleure heure', 'stats.best60Tip': 'Meilleurs 60 min GLISSANTES (pas la meilleure heure ronde) — le chiffre que citent les contesteurs.',
|
||||||
|
'stats.activeHours': 'Temps en l’air', 'stats.onAirTip': 'Fenêtre moins tous les silences de 30 min ou plus. Temps en l’air + hors antenne = la fenêtre, par construction.',
|
||||||
|
'stats.offAir': 'Hors antenne', 'stats.offAirSub': 'Silences ≥ 30 min — {d} au total',
|
||||||
|
'stats.noGaps': 'Aucune pause de 30 min ou plus.', 'stats.rateSheet': 'Feuille de cadence', 'stats.rateSheetSub': 'Heure par heure — qui a fait les QSO (heures muettes omises)', 'stats.noContest': '— Aucun contest —',
|
||||||
|
'cluster.console': 'Console', 'cluster.clear': 'Effacer', 'cluster.hideConsole': 'Masquer la console', 'cluster.showConsole': 'Afficher la console brute du cluster',
|
||||||
|
'cluster.consoleEmpty': 'Le trafic brut du cluster s’affiche ici — y compris les réponses à tes commandes (SH/DX, WHO, …).',
|
||||||
|
'msg.expand': 'Cliquer pour lire le message en entier',
|
||||||
|
'offline.queued': 'Base injoignable — QSO enregistré localement, synchro automatique',
|
||||||
|
'offline.tip': '{n} QSO en attente — la base est injoignable',
|
||||||
|
'offline.title': 'Hors ligne — {n} QSO en attente',
|
||||||
|
'offline.explain': "Enregistrés dans un fichier local, rien n'est perdu. Ils rejoindront le journal automatiquement dès que la base répondra. Attention : le « déjà contacté » ne les prend PAS en compte.",
|
||||||
|
'offline.empty': 'Rien en attente.',
|
||||||
|
'offline.retry': 'Réessayer maintenant',
|
||||||
|
'offline.synced': '{n} QSO ajoutés au journal',
|
||||||
|
'offline.stillDown': 'Base toujours injoignable — tes QSO sont en sécurité',
|
||||||
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
||||||
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.dark-warm': 'Sombre chaud',
|
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
|
||||||
|
'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
|
||||||
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',
|
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',
|
||||||
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
|
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
|
||||||
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
|
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
|
||||||
@@ -258,7 +355,7 @@ const fr: Dict = {
|
|||||||
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
|
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
|
||||||
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base',
|
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base',
|
||||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'Manipulateur CW',
|
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||||
'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||||
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
|
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
|
||||||
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
||||||
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
||||||
@@ -323,12 +420,14 @@ const fr: Dict = {
|
|||||||
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||||
'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||||
'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||||
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
||||||
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||||
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)",
|
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||||
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
|
'cat.icomNetAudioHint': 'Écoute l’audio reçu du poste sur ton périphérique d’écoute (Réglages → Audio) via le flux 50003. Expérimental — le format audio reste à vérifier sur le poste ; laisse désactivé si le contrôle se comporte mal.',
|
||||||
|
'cat.omnirigRig': 'Slot OmniRig', 'cat.flexIp': 'IP FlexRadio', 'cat.port': 'Port', 'cat.flexSpots': 'Afficher les spots cluster sur le panadapter', 'cat.flexSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur la radio, expirent après 30 min)", 'cat.flexDecodeSpots': 'Afficher les décodes WSJT-X sur le panadapter', 'cat.flexDecodeSpotsHint': '(stations FT8/FT4 entendues via ton flux UDP WSJT-X/JTDX, un spot par station)', 'cat.flexDecodeSecs': 'Affichage pendant', 'cat.flexDecodeSecsHint': 'secondes avant retrait d\'une station',
|
||||||
|
'cat.icomPort': 'Port CI-V Icom', 'cat.selectCom': 'Choisir un port COM', 'cat.noPorts': 'Aucun port trouvé', 'cat.baud': 'Débit (baud)', 'cat.icomModel': 'Modèle de poste', 'cat.icomModelOther': 'Autre (adresse perso)', 'cat.civAddr': 'Adresse CI-V (hex)', 'cat.civHint': 'Choisis ton modèle pour fixer l’adresse CI-V automatiquement (ou « Autre » et saisis-la). Mets « CI-V USB Echo Back » sur OFF et fais correspondre le débit CI-V sur le poste.',
|
||||||
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
|
'cat.tciHost': 'Hôte TCI', 'cat.tciHint': 'Active le serveur TCI dans ExpertSDR2/EESDR (Options → TCI). Port par défaut 40001. Utilise 127.0.0.1 si OpsLog tourne sur le même PC.', 'cat.tciSpots': 'Afficher les spots cluster sur le panorama', 'cat.tciSpotsHint': "(les spots du cluster DX d'OpsLog apparaissent sur le panadapter SDR)",
|
||||||
'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)',
|
'cat.pollMs': 'Intervalle de poll (ms)', 'cat.delayMs': 'Délai CAT (ms)', 'cat.digitalDefault': 'Mode numérique par défaut (quand le poste indique DIG)', 'cat.modeBeforeFreq': 'Régler le mode avant la fréquence', 'cat.modeBeforeFreqHint': '(anciens postes qui perdent le mode après un changement de bande)',
|
||||||
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
|
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
|
||||||
@@ -336,6 +435,7 @@ const fr: Dict = {
|
|||||||
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
|
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
|
||||||
'cat.ubOk': "Connecté — l'Ultrabeam a répondu avec une trame de statut.",
|
'cat.ubOk': "Connecté — l'Ultrabeam a répondu avec une trame de statut.",
|
||||||
'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.apiKey': 'Clé API', 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
|
'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.apiKey': 'Clé API', 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
|
||||||
|
'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 1–4 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce n’est pas fait.',
|
||||||
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
||||||
'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil',
|
'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil',
|
||||||
'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil',
|
'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil',
|
||||||
@@ -378,9 +478,10 @@ const fr: Dict = {
|
|||||||
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
|
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
|
||||||
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
|
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
|
||||||
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
|
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
|
||||||
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.',
|
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante',
|
||||||
|
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
|
||||||
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
|
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
|
||||||
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
|
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
|
||||||
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
|
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
|
||||||
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
|
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
|
||||||
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
|
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
|
||||||
@@ -393,7 +494,28 @@ const fr: Dict = {
|
|||||||
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.',
|
||||||
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
'awrs.group': 'Groupe', 'awrs.sub': 'Sous', 'awrs.pickReference': '← choisis une référence', 'awrs.add': 'Ajouter', 'awrs.enterCallsignFirst': "Saisis d'abord un indicatif", 'awrs.noRefsAdded': 'Aucune référence ajoutée', 'awrs.references': 'Références', 'awrs.autoMatchTitle': 'Le champ {field} vaut {code} — ce diplôme le compte automatiquement', 'awrs.fromField': 'depuis {field}', 'awrs.autoClickToAdd': 'auto — clic pour ajouter', 'awrs.search': 'Rechercher…', 'awrs.addUnlistedTitle': "Ajouter cette référence même si elle n'est pas encore dans la liste (nouvelle / non listée)", 'awrs.addPrefix': '+ Ajouter', 'awrs.unlisted': '(non listée)', 'awrs.searching': 'Recherche…', 'awrs.typeToSearch': 'Tape 2+ caractères pour chercher', 'awrs.enterCallsignOrSearch': 'Saisis un indicatif, ou tape pour chercher.', 'awrs.noRefsForEntity': 'Aucune référence pour cette entité.', 'awrs.noResults': 'Aucun résultat.', 'awrs.downloadLists': 'Télécharge les listes de références dans le panneau Diplômes → Importer les données.',
|
||||||
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
'awp.awards': 'Diplômes', 'awp.editAwards': 'Éditer les diplômes', 'awp.rescanTitle': 'Recharger le journal et recalculer (récupère les nouvelles confirmations LoTW/QRZ)', 'awp.rescan': 'Rescan', 'awp.selectAward': 'Sélectionner un diplôme…', 'awp.of': 'sur', 'awp.computing': 'Calcul…', 'awp.noData': 'Aucune donnée', 'awp.worked': 'contacté', 'awp.confirmed': 'confirmé', 'awp.validated': 'validé', 'awp.ofConfirmed': 'sur {total} · {pct}% confirmés', 'awp.byBand': 'Par bande (confirmés / contactés)', 'awp.filterReferences': 'Filtrer les références…', 'awp.filterAll': 'Tous', 'awp.filterWkd': 'Contactés', 'awp.filterNotWkd': 'Non contactés', 'awp.filterWkdNotCfmd': 'Contactés non conf.', 'awp.refs': 'réf.', 'awp.missingRefsTitle': "Contacts dans le périmètre de ce diplôme (bon DXCC/bande/mode) mais sans référence — exclus tant que tu n'en ajoutes pas", 'awp.missingRefs': 'Réf. manquantes', 'awp.gridView': 'Vue grille', 'awp.listView': 'Vue liste', 'awp.statistics': 'Statistiques', 'awp.statistic': 'Statistique', 'awp.total': 'Total', 'awp.grand': 'Général', 'awp.ref': 'Réf', 'awp.description': 'Description', 'awp.cellTitle': '{ref} · {band} — clic pour voir les QSO', 'awp.name': 'Nom', 'awp.groupCol': 'Groupe', 'awp.status': 'Statut', 'awp.bands': 'Bandes', 'awp.missing': '— manquant', 'awp.contactsMissingRef': 'contacts sans référence', 'awp.recomputeTitle': 'Recalculer — les contacts corrigés disparaissent de la liste', 'awp.refresh': 'Rafraîchir', 'awp.missingScopeHelp': 'Dans le périmètre de ce diplôme (DXCC / bande / mode / dates) mais aucune référence trouvée — ils ne comptent donc pas encore. Trie par colonne, coche les contacts concernés, puis attribue la référence ci-dessous.', 'awp.orClickRow': '(Ou clique une ligne pour ouvrir le QSO.)', 'awp.selectedArrow': '{n} sélectionné(s) →', 'awp.chooseReference': 'Choisir une référence à attribuer…', 'awp.assignToSelected': 'Attribuer à {n} sélectionné(s)', 'awp.scanning': 'Analyse…', 'awp.noGaps': "Aucun manque trouvé. (La détection de référence manquante s'applique aux diplômes limités à une entité DXCC — ex. DDFM, WAS, RAC, WAJA.)", 'awp.dateUtc': 'Date (UTC)', 'awp.callsign': 'Indicatif', 'awp.band': 'Bande', 'awp.mode': 'Mode', 'awp.country': 'Pays', 'awp.qthNote': 'QTH / Note', 'awp.stations': 'stations', 'awp.contactsWithoutRef': 'contacts sans référence', 'awp.assignedMsg': '{code}@{ref} attribué à {n} contact(s).', 'awp.loading': 'Chargement…', 'awp.noQsos': 'Aucun QSO.',
|
||||||
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.allowMultiple': 'Autoriser plusieurs références sur un seul QSO', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches supplémentaires', 'awed.orAlsoMatch': "— correspond aussi à la référence si l'une d'elles est trouvée", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.grantCodes': "Codes d'attribution", 'awed.exportCreditGranted': 'Exporter le diplôme dans le champ ADIF credit_granted', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.score': 'Score', 'awed.bonus': 'Bonus', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
'awed.addCountry': 'Ajouter un pays…', 'awed.exportedTo': 'Diplômes exportés vers :\n{path}', 'awed.importedMsg': '{awards} diplôme(s) et {references} référence(s) importés.', 'awed.awardManagement': 'Gestion des diplômes', 'awed.searchAwards': 'Rechercher un diplôme…', 'awed.newAward': 'Nouveau diplôme', 'awed.clickToDismiss': 'Cliquer pour fermer', 'awed.selectOrCreate': 'Sélectionne ou crée un diplôme.', 'awed.tabInfo': 'Infos diplôme', 'awed.tabType': 'Type de diplôme', 'awed.tabConfirmation': 'Confirmation', 'awed.tabReferences': 'Références', 'awed.awardName': 'Nom du diplôme', 'awed.valid': 'Valide', 'awed.deleteAward': 'Supprimer le diplôme', 'awed.description': 'Description', 'awed.awardUrl': 'URL du diplôme', 'awed.refDisplay': 'La colonne affiche', 'awed.refDisplayRef': 'Référence', 'awed.refDisplayName': 'Description / nom', 'awed.refDisplayBoth': 'Les deux (réf — nom)', 'awed.referenceUrl': 'URL de référence', 'awed.validFrom': 'Valide du', 'awed.validTo': 'Valide au', 'awed.dxccFilter': 'Filtre DXCC', 'awed.validBands': 'Bandes valides (vide = toutes)', 'awed.emission': 'Émission (vide = toutes)', 'awed.validModes': 'Modes valides (vide = tous)', 'awed.awardType': 'Type de diplôme', 'awed.dynamicRefs': 'Références dynamiques (non prédéfinies — toute valeur compte, comme POTA)', 'awed.qsoParams': 'Paramètres QSO (utilisés par les types QSOFIELDS / REFERENCE)', 'awed.searchInField': 'Chercher dans le champ', 'awed.matchBy': 'Correspondance par', 'awed.exactMatch': 'Correspondance exacte (sinon cherche la référence dans le champ)', 'awed.patternRegex': 'Motif (regex)', 'awed.patternPlaceholder': 'groupe 1 = référence (pour correspondance par motif / dynamique)', 'awed.leadingString': 'Chaîne de début', 'awed.trailingString': 'Chaîne de fin', 'awed.additionalSearches': 'Recherches de repli', 'awed.orAlsoMatch': "— essayées dans l'ordre, seulement si rien n'a encore été trouvé ; la première qui marche gagne", 'awed.addOr': 'Ajouter OU', 'awed.orSearchIn': 'OU — chercher dans', 'awed.exact': 'exact', 'awed.removeOrSearch': 'Supprimer cette recherche OU', 'awed.orPatternPlaceholder': 'regex — groupe 1 = référence (ex. \\b(\\d{2})\\d{3}\\b pour code postal → dépt)', 'awed.prefixPlaceholder': 'préfixe (D)', 'awed.prefixTitle': 'Ajouté devant chaque référence trouvée, ex. 74 → D74', 'awed.confirmationLabel': 'Confirmation (contacté → confirmé)', 'awed.validationLabel': 'Validation (confirmé → validé)', 'awed.resetDefaults': 'Réinitialiser par défaut', 'awed.exportTitle': 'Exporter toutes les définitions de diplômes + listes de références vers une sauvegarde JSON', 'awed.export': 'Exporter…', 'awed.importTitle': 'Importer un lot de diplômes (définitions + listes de références)', 'awed.import': 'Importer…', 'awed.cancel': 'Annuler', 'awed.save': 'Enregistrer', 'awed.populatedMsg': '{n} références intégrées ajoutées.', 'awed.newRefCodePrompt': 'Nouveau code de référence :', 'awed.importedRefsMsg': '{n} références importées.', 'awed.referenceCount': 'Nombre de références :', 'awed.applyPreset': 'Appliquer un préréglage…', 'awed.pasteCsv': 'Coller / CSV', 'awed.populateBuiltinTitle': 'Remplacer par la liste intégrée fournie (entités DXCC, départements français, …)', 'awed.populateBuiltin': "Charger l'intégrée", 'awed.updateOnline': 'Mettre à jour en ligne', 'awed.add': 'Ajouter', 'awed.onePerLine': 'Une référence par ligne :', 'awed.replacesList': '(virgule/point-virgule/tab). Remplace toute la liste.', 'awed.import2': 'Importer', 'awed.search': 'Rechercher…', 'awed.searching': 'Recherche…', 'awed.tooManyItems': "Trop d'éléments ({total}). Affine la recherche (tape 2+ caractères).", 'awed.noReferences': 'Aucune référence.', 'awed.selectReference': 'Sélectionne une référence, ou Ajouter / importer une liste.', 'awed.group': 'Groupe', 'awed.subgroup': 'Sous-groupe', 'awed.perRefRegex': 'regex optionnelle par référence', 'awed.grid': 'Locator', 'awed.saveReference': 'Enregistrer la référence',
|
||||||
|
'awed.updateAvailable': 'Une nouvelle version de ce diplôme est disponible', 'awed.updateOverwrites': "Tu as modifié ce diplôme, la mise à jour n'a donc pas été appliquée. L'accepter remplacera ta définition et ta liste de références.", 'awed.updateApply': 'Mettre à jour', 'awed.updateKeepMine': 'Garder les miennes',
|
||||||
|
'awed.tabTest': 'Test', 'awed.testCallsign': 'Tester avec un indicatif', 'awed.testRun': 'Tester', 'awed.testSavedOnly': 'Teste le diplôme ENREGISTRÉ — enregistre tes modifications avant.', 'awed.testNoMatch': 'aucune correspondance', 'awed.testOutOfScope': "QSO hors périmètre — aucune règle n'a été exécutée.", 'awed.testSkipped': "non exécutée : une règle précédente a déjà trouvé", 'awed.testFieldValue': 'Champ', 'awed.testEmptyField': 'vide', 'awed.testNoCandidate': "n'a produit aucun candidat", 'awed.testManual': 'Référence forcée à la main', 'awed.testSameAs': '+{n} autre(s) QSO, même résultat',
|
||||||
|
'awed.exportOne': 'Partager {code}',
|
||||||
|
'awed.onlyHere': 'local',
|
||||||
|
'awed.onlyHereTip': 'À toi — non livré avec OpsLog. Son JSON est tenu à jour dans le dossier awards ; envoie ce fichier pour le partager.',
|
||||||
|
'awed.builtin': 'Intégré',
|
||||||
|
'awed.awardsFolder': 'Dossier awards',
|
||||||
|
'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.',
|
||||||
|
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez l’utilisateur — alors que tu l’as livré.',
|
||||||
|
'awed.protectedFlag': 'Protégé',
|
||||||
|
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
|
||||||
|
'awed.exportOneTitle': 'Exporter CE diplôme seul (définition + références + leurs regex) — le fichier que tu envoies à quelqu’un',
|
||||||
|
'awed.importCollisionTitle': 'Certains de ces diplômes existent déjà',
|
||||||
|
'awed.importCollisionHint': 'Rien n’est remplacé sans ton accord. « Importer en copie » installe le sien à côté du tien : tu compares, puis tu supprimes l’un des deux.',
|
||||||
|
'awed.importRefs': '{n} référence(s)',
|
||||||
|
'awed.importNew': 'Nouveau — sera ajouté.',
|
||||||
|
'awed.importExists': 'Tu as déjà « {name} » avec {n} référence(s).',
|
||||||
|
'awed.importProtected': 'intégré',
|
||||||
|
'awed.importKeepMine': 'Garder le mien',
|
||||||
|
'awed.importReplace': 'Remplacer le mien',
|
||||||
|
'awed.importCopy': 'Importer en {code}-2',
|
||||||
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
'qctx.selected': '{n} QSO sélectionné(s)', 'qctx.fixCountry': 'Corriger pays et zones depuis cty.dat', 'qctx.updateQrz': 'Mettre à jour depuis QRZ.com', 'qctx.updateClublog': 'Mettre à jour depuis ClubLog (exceptions)', 'qctx.sendQslEmail': 'Envoyer la QSL OpsLog par e-mail', 'qctx.sendRecording': "Envoyer l'enregistrement par e-mail", 'qctx.bulkEdit': "Édition groupée d'un champ… ({n})", 'qctx.exportSelectedAdif': 'Exporter la sélection en ADIF ({n})', 'qctx.exportFilteredAdif': 'Exporter la vue filtrée en ADIF (sans limite)', 'qctx.exportSelectedCabrillo': 'Exporter la sélection en Cabrillo ({n})', 'qctx.exportFilteredCabrillo': 'Exporter la vue filtrée en Cabrillo (sans limite)', 'qctx.sendTo': 'Envoyer vers {name}', 'qctx.delete': 'Supprimer {n} QSO…',
|
||||||
'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fContestId': 'ID concours', 'bulk.fSrxString': 'Série reçue (échange)', 'bulk.fStxString': 'Série envoyée (échange)', 'bulk.fArrlSect': 'Section ARRL', 'bulk.fPrecedence': 'Précédence', 'bulk.fClass': 'Classe', 'bulk.fPropMode': 'Mode de propagation', 'bulk.fSatName': 'Nom du satellite', 'bulk.fSatMode': 'Mode satellite', 'bulk.fPotaRef': 'Réf POTA', 'bulk.fSotaRef': 'Réf SOTA', 'bulk.fWwffRef': 'Réf WWFF', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'Info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupContacted': 'Station contactée', 'bulk.groupContest': 'Concours', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}',
|
'bulk.fLotwSent': 'LoTW envoyé', 'bulk.fLotwRcvd': 'LoTW reçu', 'bulk.fEqslSent': 'eQSL envoyé', 'bulk.fEqslRcvd': 'eQSL reçu', 'bulk.fQslSent': 'QSL papier envoyée', 'bulk.fQslRcvd': 'QSL papier reçue', 'bulk.fQrzUpload': 'Envoi QRZ.com', 'bulk.fClublogUpload': 'Envoi Club Log', 'bulk.fHrdlogUpload': 'Envoi HRDLog', 'bulk.fQslVia': 'QSL via', 'bulk.fStationCall': 'Indicatif de la station', 'bulk.fOperator': 'Opérateur', 'bulk.fMyGrid': 'Mon locator', 'bulk.fMyAntenna': 'Mon antenne', 'bulk.fMyRig': 'Mon équipement', 'bulk.fMyStreet': 'Ma rue', 'bulk.fMyCity': 'Ma ville', 'bulk.fMyPostal': 'Mon code postal', 'bulk.fMyCountry': 'Mon pays', 'bulk.fMyState': 'Mon état', 'bulk.fMyCounty': 'Mon comté', 'bulk.fMyIota': 'Mon IOTA', 'bulk.fMySota': 'Ma réf. SOTA', 'bulk.fMyPota': 'Ma réf. POTA', 'bulk.fMyWwff': 'Ma réf. WWFF', 'bulk.fMySig': 'Mon SIG', 'bulk.fMySigInfo': 'Mon info SIG', 'bulk.fContestId': 'ID concours', 'bulk.fSrxString': 'Série reçue (échange)', 'bulk.fStxString': 'Série envoyée (échange)', 'bulk.fArrlSect': 'Section ARRL', 'bulk.fPrecedence': 'Précédence', 'bulk.fClass': 'Classe', 'bulk.fPropMode': 'Mode de propagation', 'bulk.fSatName': 'Nom du satellite', 'bulk.fSatMode': 'Mode satellite', 'bulk.fPotaRef': 'Réf POTA', 'bulk.fSotaRef': 'Réf SOTA', 'bulk.fWwffRef': 'Réf WWFF', 'bulk.fIota': 'IOTA', 'bulk.fSig': 'SIG', 'bulk.fSigInfo': 'Info SIG', 'bulk.fComment': 'Commentaire', 'bulk.fNotes': 'Notes', 'bulk.fRig': 'Équipement (contacté)', 'bulk.fAnt': 'Antenne (contactée)', 'bulk.statusY': 'Y — Oui / envoyé', 'bulk.statusN': 'N — Non', 'bulk.statusR': 'R — Demandé', 'bulk.statusI': 'I — Ignorer', 'bulk.statusBlank': '(vide — effacer)', 'bulk.groupQsl': 'QSL / envoi', 'bulk.groupMyStation': 'Ma station', 'bulk.groupContacted': 'Station contactée', 'bulk.groupContest': 'Concours', 'bulk.groupPropagation': 'Propagation', 'bulk.groupMisc': 'Divers', 'bulk.title': "Édition groupée d'un champ", 'bulk.desc': 'Définir un champ sur les {n} QSO sélectionné(s). Cela écrase la valeur actuelle — aucune annulation possible.', 'bulk.fieldLabel': 'Champ', 'bulk.valueLabel': 'Valeur', 'bulk.clearPlaceholder': 'laisser vide pour effacer le champ', 'bulk.willSet': 'Définira', 'bulk.blank': '(vide)', 'bulk.onQsos': 'sur {n} QSO.', 'bulk.cancel': 'Annuler', 'bulk.applyTo': 'Appliquer à {n}',
|
||||||
'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}',
|
'qslm.leave': '— laisser —', 'qslm.yes': 'Oui', 'qslm.no': 'Non', 'qslm.requested': 'Demandé', 'qslm.ignore': 'Ignorer', 'qslm.viaBureau': 'Bureau', 'qslm.viaDirect': 'Direct', 'qslm.viaElectronic': 'Électronique', 'qslm.svcPota': 'Journal chasseur POTA', 'qslm.svcPaper': 'QSL papier', 'qslm.sentRequested': 'Demandé', 'qslm.sentNo': 'Non', 'qslm.sentQueued': 'En file', 'qslm.sentYes': 'Oui (déjà envoyé)', 'qslm.sentInvalid': 'Invalide', 'qslm.sentBlank': '— vide —', 'qslm.qsoUpdated': '{n} QSO mis à jour.', 'qslm.service': 'Service', 'qslm.callsign': 'Indicatif', 'qslm.callsignScopeTitle': "L'envoi/téléchargement est limité à cet indicatif (indicatif de station forcé, sinon celui du profil actif)", 'qslm.syncHunterLog': 'Synchroniser le journal chasseur', 'qslm.onlyMyCallTitle': "Ne synchroniser que les chasses faites sous l'indicatif de votre profil actif — ignorer les QSO faits sous un autre indicatif (ex. XV9Q, NQ2H) absents de ce journal", 'qslm.onlyMyCall': "Uniquement l'indicatif de mon profil", 'qslm.addMissingTitle': "Insérer les contacts du journal chasseur dont l'indicatif n'est pas encore dans votre journal (indicatif/date/bande/mode/parc)", 'qslm.addMissing': 'Ajouter les QSO introuvables à mon journal', 'qslm.potaToken': 'Jeton dans Réglages → Services externes → POTA.', 'qslm.callsignPlaceholder': 'ex. DL1ABC', 'qslm.search': 'Rechercher', 'qslm.paperHint': 'Trouvez un indicatif, puis définissez QSL envoyée/reçue + via + date sur la sélection.', 'qslm.sentStatus': 'Statut envoyé', 'qslm.selectRequired': 'Sélectionner les requis', 'qslm.potaSummaryShort': '{updated} mis à jour · {added} ajoutés · {already} déjà · {unmatched} sans correspondance', 'qslm.potaOtherCall': ' · {n} autre indicatif', 'qslm.paperCount': '{total} QSO · {selected} sélectionné(s)', 'qslm.filter': 'Filtre', 'qslm.filterAll': 'Tous', 'qslm.filterNew': 'Nouveau (tout)', 'qslm.filterNewDxcc': 'Nouveau DXCC', 'qslm.filterNewBand': 'Nouvelle bande', 'qslm.filterNewSlot': 'Nouveau créneau', 'qslm.results': 'Résultats', 'qslm.log': 'Journal', 'qslm.confCount': '{shown} / {total} confirmation(s)', 'qslm.foundCount': '{found} trouvé(s) · {selected} sélectionné(s)', 'qslm.paperEmpty': 'Recherchez un indicatif pour lister ses QSO, puis définissez le statut QSL ci-dessous.', 'qslm.potaEmpty': 'Cliquez sur « Synchroniser le journal chasseur » pour récupérer votre journal pota.app et tamponner les références de parc.', 'qslm.potaSyncing': 'Synchronisation avec pota.app…', 'qslm.potaSummary': '{updated} QSO mis à jour · {added} ajoutés au journal · {already} déjà tamponnés · {unmatched} sans correspondance (sur {fetched} entrées du journal chasseur).', 'qslm.potaSkipped': ' {n} chasse(s) faites sous un autre indicatif ont été ignorées', 'qslm.potaKeptOnly': ' (conservé uniquement {call})', 'qslm.potaRescan': 'Relancez le scan du diplôme POTA pour compter les nouvelles références.', 'qslm.thActivator': 'Activateur', 'qslm.thDateUtc': 'Date UTC', 'qslm.thBand': 'Bande', 'qslm.thPark': 'Parc', 'qslm.thWhyUnmatched': 'Pourquoi sans correspondance', 'qslm.openToFix': 'Ouvrir ce QSO pour le corriger', 'qslm.starting': 'démarrage…', 'qslm.working': 'en cours…', 'qslm.noNewConf': 'Aucune nouvelle confirmation.', 'qslm.noConfMatch': 'Aucune confirmation ne correspond à ce filtre.', 'qslm.thCallsign': 'Indicatif', 'qslm.thMode': 'Mode', 'qslm.thCountry': 'Pays', 'qslm.thNew': 'Nouveau ?', 'qslm.newDxcc': 'NOUVEAU DXCC', 'qslm.newBand': 'NOUVELLE BANDE', 'qslm.newSlot': 'NOUVEAU CRÉNEAU', 'qslm.uploadEmpty': 'Choisissez un service + statut envoyé, puis « Sélectionner les requis ».', 'qslm.qslReceived': 'QSL reçue', 'qslm.qslRcvdDateTitle': 'Date de réception QSL', 'qslm.qslSent': 'QSL envoyée', 'qslm.qslSentDateTitle': "Date d'envoi QSL", 'qslm.via': 'Via', 'qslm.notes': 'Notes', 'qslm.notesPlaceholder': 'ex. payé 3€', 'qslm.comment': 'Commentaire', 'qslm.commentPlaceholder': 'commentaire', 'qslm.applyToSelected': 'Appliquer à {n} sélectionné(s)', 'qslm.downloadTitle': 'Récupérer les confirmations du service et mettre à jour le statut reçu', 'qslm.downloadConf': 'Télécharger les confirmations', 'qslm.downloadRangeTitle': "Jusqu'où télécharger", 'qslm.sinceLast': 'Depuis le dernier téléchargement', 'qslm.sinceDate': 'Depuis une date…', 'qslm.sinceAll': 'Tout', 'qslm.sinceDateTitleQrz': 'QRZ : filtre par date de QSO (pas de filtre par date de réception côté serveur)', 'qslm.sinceDateTitleLotw': 'LoTW : confirmations reçues depuis cette date', 'qslm.addNotFoundTitle': "Insérer les QSO confirmés qui ne sont pas encore dans votre journal", 'qslm.addNotFound': 'Ajouter les introuvables', 'qslm.uploadTo': 'Envoyer {n} vers {service}',
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { writeUiPref } from './uiPref';
|
|||||||
// 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
|
||||||
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
// preference. The choice is persisted (localStorage + portable UI pref, so it
|
||||||
// travels with the data/ folder like the language).
|
// travels with the data/ folder like the language).
|
||||||
export type ThemeChoice = 'auto' | 'light-warm' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
export type ThemeChoice = 'auto' | 'light-warm' | 'light-cool' | 'light-sage' | 'dim-slate' | 'dark-warm' | 'dark-graphite' | 'high-contrast';
|
||||||
|
|
||||||
// Selectable, concrete themes (excludes 'auto') in display order.
|
// Selectable, concrete themes (excludes 'auto') in display order.
|
||||||
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
export const CONCRETE_THEMES: Exclude<ThemeChoice, 'auto'>[] = [
|
||||||
'light-warm', 'dark-warm', 'dark-graphite', 'high-contrast',
|
'light-warm', 'light-cool', 'light-sage', 'dim-slate', 'dark-warm', 'dark-graphite', 'high-contrast',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LS_KEY = 'opslog.theme';
|
export const LS_KEY = 'opslog.theme';
|
||||||
|
|||||||
@@ -92,6 +92,207 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1b: Cool light (slate/blue) — crisp daylight, neutral cool --- */
|
||||||
|
[data-theme="light-cool"] {
|
||||||
|
--background: #f4f6f8; /* cool blue-white */
|
||||||
|
--foreground: #0f172a; /* slate-900 */
|
||||||
|
--card: #ffffff;
|
||||||
|
--card-foreground: #0f172a;
|
||||||
|
--popover: #ffffff;
|
||||||
|
--popover-foreground: #0f172a;
|
||||||
|
--primary: #2563eb; /* blue-600 */
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: #e2e8f0; /* slate-200 */
|
||||||
|
--secondary-foreground: #0f172a;
|
||||||
|
--muted: #e6ebf1; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #475569; /* slate-600 — readable on muted */
|
||||||
|
--accent: #dbeafe; /* blue-100 */
|
||||||
|
--accent-foreground: #1e3a8a; /* blue-900 */
|
||||||
|
--destructive: #dc2626;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--destructive-muted: #fef2f2;
|
||||||
|
--destructive-muted-foreground: #b91c1c;
|
||||||
|
--border: #d3dae2; /* cool slate border */
|
||||||
|
--input: #d3dae2;
|
||||||
|
--ring: #3b82f6; /* blue-500 focus ring */
|
||||||
|
|
||||||
|
--success: #16a34a;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcfce7;
|
||||||
|
--success-muted-foreground: #166534;
|
||||||
|
--success-border: #86efac;
|
||||||
|
|
||||||
|
--warning: #d97706;
|
||||||
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fef3c7;
|
||||||
|
--warning-muted-foreground: #92400e;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
|
||||||
|
--caution: #ca8a04;
|
||||||
|
--caution-foreground: #1c1917;
|
||||||
|
--caution-muted: #fef9c3;
|
||||||
|
--caution-muted-foreground: #854d0e;
|
||||||
|
--caution-border: #fde047;
|
||||||
|
|
||||||
|
--danger: #e11d48;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #ffe4e6;
|
||||||
|
--danger-muted-foreground: #9f1239;
|
||||||
|
--danger-border: #fda4af;
|
||||||
|
|
||||||
|
--info: #0284c7;
|
||||||
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0f2fe;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #7dd3fc;
|
||||||
|
|
||||||
|
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, cool neutral base. */
|
||||||
|
--mx-call-conf: #15803d;
|
||||||
|
--mx-call-work: #6ee7b7;
|
||||||
|
--mx-dx-conf: #3730a3;
|
||||||
|
--mx-dx-work: #a5b4fc;
|
||||||
|
--mx-none: #e2e8f0;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #b6c2d1;
|
||||||
|
--scrollbar-thumb-hover: #8fa1b5;
|
||||||
|
--card-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(15, 23, 42, 0.02);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1c: Sage light (Nordic) — soft green-grey, low-fatigue ------- */
|
||||||
|
[data-theme="light-sage"] {
|
||||||
|
--background: #eef1ec; /* pale sage */
|
||||||
|
--foreground: #1a2420; /* deep pine-ink */
|
||||||
|
--card: #f8faf6; /* lifted off-white with a green cast */
|
||||||
|
--card-foreground: #1a2420;
|
||||||
|
--popover: #f8faf6;
|
||||||
|
--popover-foreground: #1a2420;
|
||||||
|
--primary: #2f855a; /* deep sage green */
|
||||||
|
--primary-foreground: #f0fdf4;
|
||||||
|
--secondary: #dde5db;
|
||||||
|
--secondary-foreground: #1a2420;
|
||||||
|
--muted: #dde5db; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #445048; /* muted pine — readable on muted */
|
||||||
|
--accent: #d7e8d5; /* soft green */
|
||||||
|
--accent-foreground: #1e4634; /* forest-900 */
|
||||||
|
--destructive: #b91c1c;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
--destructive-muted: #fef2f2;
|
||||||
|
--destructive-muted-foreground: #b91c1c;
|
||||||
|
--border: #cdd8ce; /* soft sage border */
|
||||||
|
--input: #cdd8ce;
|
||||||
|
--ring: #38a169; /* green focus ring */
|
||||||
|
|
||||||
|
--success: #16a34a;
|
||||||
|
--success-foreground: #ffffff;
|
||||||
|
--success-muted: #dcfce7;
|
||||||
|
--success-muted-foreground: #166534;
|
||||||
|
--success-border: #86efac;
|
||||||
|
|
||||||
|
--warning: #d97706;
|
||||||
|
--warning-foreground: #ffffff;
|
||||||
|
--warning-muted: #fef3c7;
|
||||||
|
--warning-muted-foreground: #92400e;
|
||||||
|
--warning-border: #fcd34d;
|
||||||
|
|
||||||
|
--caution: #ca8a04;
|
||||||
|
--caution-foreground: #1c1917;
|
||||||
|
--caution-muted: #fef9c3;
|
||||||
|
--caution-muted-foreground: #854d0e;
|
||||||
|
--caution-border: #fde047;
|
||||||
|
|
||||||
|
--danger: #e11d48;
|
||||||
|
--danger-foreground: #ffffff;
|
||||||
|
--danger-muted: #ffe4e6;
|
||||||
|
--danger-muted-foreground: #9f1239;
|
||||||
|
--danger-border: #fda4af;
|
||||||
|
|
||||||
|
--info: #0284c7;
|
||||||
|
--info-foreground: #ffffff;
|
||||||
|
--info-muted: #e0f2fe;
|
||||||
|
--info-muted-foreground: #075985;
|
||||||
|
--info-border: #7dd3fc;
|
||||||
|
|
||||||
|
/* Band/mode matrix (BandSlotGrid) — emerald/indigo ramp, sage neutral base. */
|
||||||
|
--mx-call-conf: #15803d;
|
||||||
|
--mx-call-work: #6ee7b7;
|
||||||
|
--mx-dx-conf: #3730a3;
|
||||||
|
--mx-dx-work: #a5b4fc;
|
||||||
|
--mx-none: #dde5db;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #b3c2b3;
|
||||||
|
--scrollbar-thumb-hover: #91a591;
|
||||||
|
--card-shadow: 0 1px 2px rgba(26, 36, 32, 0.06), 0 0 0 1px rgba(26, 36, 32, 0.02);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Theme 1d: Dim slate — mid-tone twilight, between light and dark ---- */
|
||||||
|
[data-theme="dim-slate"] {
|
||||||
|
--background: #343b47; /* medium slate — light text, not deep dark */
|
||||||
|
--foreground: #eceef2;
|
||||||
|
--card: #3d4552; /* lifted panel */
|
||||||
|
--card-foreground: #eceef2;
|
||||||
|
--popover: #3d4552;
|
||||||
|
--popover-foreground: #eceef2;
|
||||||
|
--primary: #fb923c; /* orange-400 — brand warmth, pops on slate */
|
||||||
|
--primary-foreground: #241206;
|
||||||
|
--secondary: #454e5c;
|
||||||
|
--secondary-foreground: #eceef2;
|
||||||
|
--muted: #414a58; /* toolbars / table headers */
|
||||||
|
--muted-foreground: #b4bcc9; /* readable on muted */
|
||||||
|
--accent: #495468; /* hover / selection tint */
|
||||||
|
--accent-foreground: #dce3ef;
|
||||||
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #1a0808;
|
||||||
|
--destructive-muted: #3f1f1f;
|
||||||
|
--destructive-muted-foreground: #fca5a5;
|
||||||
|
--border: #4e5867;
|
||||||
|
--input: #4e5867;
|
||||||
|
--ring: #fdba74; /* orange-300 focus ring */
|
||||||
|
|
||||||
|
--success: #34d399;
|
||||||
|
--success-foreground: #06231a;
|
||||||
|
--success-muted: #1c3d34;
|
||||||
|
--success-muted-foreground: #6ee7b7;
|
||||||
|
--success-border: #2a5f4c;
|
||||||
|
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--warning-foreground: #241a06;
|
||||||
|
--warning-muted: #41371a;
|
||||||
|
--warning-muted-foreground: #fcd34d;
|
||||||
|
--warning-border: #5e4d22;
|
||||||
|
|
||||||
|
--caution: #facc15;
|
||||||
|
--caution-foreground: #242006;
|
||||||
|
--caution-muted: #403b1a;
|
||||||
|
--caution-muted-foreground: #fde047;
|
||||||
|
--caution-border: #5b5222;
|
||||||
|
|
||||||
|
--danger: #fb7185;
|
||||||
|
--danger-foreground: #260a11;
|
||||||
|
--danger-muted: #421f28;
|
||||||
|
--danger-muted-foreground: #fda4af;
|
||||||
|
--danger-border: #63303c;
|
||||||
|
|
||||||
|
--info: #38bdf8;
|
||||||
|
--info-foreground: #061f2b;
|
||||||
|
--info-muted: #16384a;
|
||||||
|
--info-muted-foreground: #7dd3fc;
|
||||||
|
--info-border: #1d5670;
|
||||||
|
|
||||||
|
/* Matrix: bright confirmed, medium worked, slate not-worked (sits on dim). */
|
||||||
|
--mx-call-conf: #22c55e;
|
||||||
|
--mx-call-work: #2f7d55;
|
||||||
|
--mx-dx-conf: #6366f1;
|
||||||
|
--mx-dx-work: #3f3f8a;
|
||||||
|
--mx-none: #4a5262;
|
||||||
|
|
||||||
|
--scrollbar-thumb: #565f70;
|
||||||
|
--scrollbar-thumb-hover: #6b7588;
|
||||||
|
--card-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(236, 238, 242, 0.05);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
|
/* ---- Theme 2: Warm dark (espresso) — same soul, night mode ------------- */
|
||||||
[data-theme="dark-warm"] {
|
[data-theme="dark-warm"] {
|
||||||
--background: #221d18;
|
--background: #221d18;
|
||||||
@@ -294,6 +495,63 @@
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Data-viz palette (Statistics dashboard) ────────────────────────────────
|
||||||
|
A VALIDATED categorical palette, not hand-picked: the slot ORDER is what makes
|
||||||
|
it colour-blind-safe (worst adjacent ΔE 24.2 light / 10.3 dark), so never
|
||||||
|
reorder, recycle, or generate a 9th hue — fold the tail into "Other" instead.
|
||||||
|
The dark column is the same eight hues re-stepped for a dark surface, NOT an
|
||||||
|
automatic inversion.
|
||||||
|
|
||||||
|
--chart-1 is the DEFAULT single-series hue: a chart with one series uses it for
|
||||||
|
every bar. Colour is for identity, never to re-encode a length the bar already
|
||||||
|
shows.
|
||||||
|
|
||||||
|
--chart-seq-* is a ONE-HUE ramp for magnitude (the hour×band heatmap). On light
|
||||||
|
themes it runs light→dark; on dark themes dark→light, so "near zero" always
|
||||||
|
recedes toward the surface instead of glowing. */
|
||||||
|
:root,
|
||||||
|
[data-theme="light-warm"],
|
||||||
|
[data-theme="light-cool"],
|
||||||
|
[data-theme="light-sage"] {
|
||||||
|
--chart-1: #2a78d6; /* blue — default single-series hue */
|
||||||
|
--chart-2: #1baf7a; /* aqua */
|
||||||
|
--chart-3: #eda100; /* yellow */
|
||||||
|
--chart-4: #008300; /* green */
|
||||||
|
--chart-5: #4a3aa7; /* violet */
|
||||||
|
--chart-6: #e34948; /* red */
|
||||||
|
--chart-7: #e87ba4; /* magenta */
|
||||||
|
--chart-8: #eb6834; /* orange */
|
||||||
|
|
||||||
|
--chart-seq-1: #cde2fb;
|
||||||
|
--chart-seq-2: #9ec5f4;
|
||||||
|
--chart-seq-3: #6da7ec;
|
||||||
|
--chart-seq-4: #3987e5;
|
||||||
|
--chart-seq-5: #256abf;
|
||||||
|
--chart-seq-6: #104281;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dim-slate"],
|
||||||
|
[data-theme="dark-warm"],
|
||||||
|
[data-theme="dark-graphite"],
|
||||||
|
[data-theme="high-contrast"] {
|
||||||
|
--chart-1: #3987e5;
|
||||||
|
--chart-2: #199e70;
|
||||||
|
--chart-3: #c98500;
|
||||||
|
--chart-4: #008300;
|
||||||
|
--chart-5: #9085e9;
|
||||||
|
--chart-6: #e66767;
|
||||||
|
--chart-7: #d55181;
|
||||||
|
--chart-8: #d95926;
|
||||||
|
|
||||||
|
/* Reversed: the lowest step must sink toward the dark surface. */
|
||||||
|
--chart-seq-1: #0d366b;
|
||||||
|
--chart-seq-2: #184f95;
|
||||||
|
--chart-seq-3: #256abf;
|
||||||
|
--chart-seq-4: #3987e5;
|
||||||
|
--chart-seq-5: #6da7ec;
|
||||||
|
--chart-seq-6: #9ec5f4;
|
||||||
|
}
|
||||||
|
|
||||||
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
|
/* Map Tailwind's color utilities onto the semantic vars above. `inline` makes
|
||||||
the generated utilities reference var(--…) directly, so overriding a var in
|
the generated utilities reference var(--…) directly, so overriding a var in
|
||||||
a [data-theme] block re-skins every utility at runtime. */
|
a [data-theme] block re-skins every utility at runtime. */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// 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.1';
|
export const APP_VERSION = '0.19.7';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+70
@@ -11,12 +11,14 @@ import {awardref} from '../models';
|
|||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
|
import {solar} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
import {alerts} from '../models';
|
import {alerts} from '../models';
|
||||||
import {audio} from '../models';
|
import {audio} from '../models';
|
||||||
import {contest} from '../models';
|
import {contest} from '../models';
|
||||||
import {operating} from '../models';
|
import {operating} from '../models';
|
||||||
import {udp} from '../models';
|
import {udp} from '../models';
|
||||||
|
import {lotwusers} from '../models';
|
||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
import {netctl} from '../models';
|
import {netctl} from '../models';
|
||||||
|
|
||||||
@@ -32,10 +34,26 @@ export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
|||||||
|
|
||||||
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function ApplyAwardImport(arg1:string,arg2:Record<string, string>):Promise<main.AwardImportResult>;
|
||||||
|
|
||||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
export function 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 AudioStartMonitor():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioStartTX():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioStopMonitor():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioStopTX():Promise<void>;
|
||||||
|
|
||||||
|
export function AudioTXActive():Promise<boolean>;
|
||||||
|
|
||||||
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function AwardFields():Promise<Array<string>>;
|
export function AwardFields():Promise<Array<string>>;
|
||||||
@@ -44,6 +62,8 @@ export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
|||||||
|
|
||||||
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
||||||
|
|
||||||
|
export function AwardsFolder():Promise<string>;
|
||||||
|
|
||||||
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>;
|
||||||
@@ -120,20 +140,28 @@ 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>;
|
||||||
|
|
||||||
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function DownloadLoTWUsers():Promise<number>;
|
||||||
|
|
||||||
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>;
|
||||||
|
|
||||||
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
||||||
|
|
||||||
|
export function ExportAward(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function ExportAwards():Promise<string>;
|
export function ExportAwards():Promise<string>;
|
||||||
|
|
||||||
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
|
||||||
@@ -190,6 +218,8 @@ export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetMic(arg1:number):Promise<void>;
|
export function FlexSetMic(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetMicProfile(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMon(arg1:boolean):Promise<void>;
|
export function FlexSetMon(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
||||||
@@ -210,6 +240,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>;
|
||||||
@@ -218,6 +252,8 @@ export function FlexSetSplit(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
export function FlexSetTXAntenna(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetTXFilter(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
export function FlexSetTXSlice(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexSetTunePower(arg1:number):Promise<void>;
|
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||||
@@ -228,6 +264,14 @@ export function FlexSetVoxDelay(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function FlexSetVoxLevel(arg1:number):Promise<void>;
|
export function FlexSetVoxLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetWNB(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetWNBLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetXIT(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetXITFreq(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function FlexTune(arg1:boolean):Promise<void>;
|
export function FlexTune(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
@@ -252,6 +296,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>;
|
||||||
@@ -262,6 +308,8 @@ export function GetCATState():Promise<cat.RigState>;
|
|||||||
|
|
||||||
export function GetCWDecoderPitch():Promise<number>;
|
export function GetCWDecoderPitch():Promise<number>;
|
||||||
|
|
||||||
|
export function GetCatalogCodes():Promise<Array<string>>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
@@ -270,6 +318,8 @@ export function GetClusterAutoConnect():Promise<boolean>;
|
|||||||
|
|
||||||
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
|
||||||
|
|
||||||
|
export function GetContestRuns():Promise<Array<qso.ContestRun>>;
|
||||||
|
|
||||||
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
export function GetDBBackendStatus():Promise<main.DBBackendStatus>;
|
||||||
@@ -298,14 +348,20 @@ export function GetListsSettings():Promise<main.ListsSettings>;
|
|||||||
|
|
||||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||||
|
|
||||||
|
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 GetLogbookRevision():Promise<string>;
|
export function GetLogbookRevision():Promise<string>;
|
||||||
|
|
||||||
export function GetLookupSettings():Promise<main.LookupSettings>;
|
export function GetLookupSettings():Promise<main.LookupSettings>;
|
||||||
|
|
||||||
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||||
|
|
||||||
|
export function GetOfflineStatus():Promise<main.OfflineStatus>;
|
||||||
|
|
||||||
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||||
|
|
||||||
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||||
@@ -314,6 +370,8 @@ export function GetPGXLStatus():Promise<powergenius.Status>;
|
|||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
|
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
|
|
||||||
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
export function GetQSO(arg1:number):Promise<qso.QSO>;
|
||||||
@@ -324,6 +382,8 @@ export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
|||||||
|
|
||||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
|
|
||||||
|
export function GetSolarData():Promise<solar.Data>;
|
||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
@@ -434,6 +494,8 @@ export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<numbe
|
|||||||
|
|
||||||
export function ImportAwards():Promise<main.AwardImportResult>;
|
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||||
|
|
||||||
|
export function InspectAwardImport():Promise<main.AwardImportPreview>;
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
export function LaunchAutostartProgram(arg1:string):Promise<main.AutostartLaunchResult>;
|
||||||
|
|
||||||
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
export function LaunchAutostartPrograms():Promise<Array<main.AutostartLaunchResult>>;
|
||||||
@@ -466,6 +528,8 @@ export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>
|
|||||||
|
|
||||||
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
export function ListUDPIntegrations():Promise<Array<udp.Config>>;
|
||||||
|
|
||||||
|
export function LoTWUserInfo(arg1:string):Promise<lotwusers.Info>;
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
export function LogUDPLoggedADIF(arg1:string):Promise<number>;
|
||||||
|
|
||||||
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
export function LookupCallsign(arg1:string):Promise<lookup.Result>;
|
||||||
@@ -508,6 +572,8 @@ export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
|
|||||||
|
|
||||||
export function OpenADIFFile():Promise<string>;
|
export function OpenADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function OpenAwardsFolder():Promise<void>;
|
||||||
|
|
||||||
export function OpenDatabase(arg1:string):Promise<void>;
|
export function OpenDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function OpenExternalURL(arg1:string):Promise<void>;
|
export function OpenExternalURL(arg1:string):Promise<void>;
|
||||||
@@ -572,6 +638,8 @@ export function QuitApp():Promise<void>;
|
|||||||
|
|
||||||
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
export function RefreshCtyDat():Promise<main.CtyDatInfo>;
|
||||||
|
|
||||||
|
export function RefreshSolar():Promise<void>;
|
||||||
|
|
||||||
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
export function ReloadUDPIntegrations():Promise<Array<string>>;
|
||||||
|
|
||||||
export function RemovePassphrase(arg1:string):Promise<void>;
|
export function RemovePassphrase(arg1:string):Promise<void>;
|
||||||
@@ -592,6 +660,8 @@ export function RestartApp():Promise<void>;
|
|||||||
|
|
||||||
export function RestartQSORecorder():Promise<void>;
|
export function RestartQSORecorder():Promise<void>;
|
||||||
|
|
||||||
|
export function RetryOfflineSync():Promise<number>;
|
||||||
|
|
||||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function RotatorPark():Promise<void>;
|
export function RotatorPark():Promise<void>;
|
||||||
|
|||||||
@@ -26,14 +26,46 @@ export function AntGeniusDeselect(arg1) {
|
|||||||
return window['go']['main']['App']['AntGeniusDeselect'](arg1);
|
return window['go']['main']['App']['AntGeniusDeselect'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ApplyAwardImport(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['ApplyAwardImport'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function ApplyAwardPreset(arg1, arg2) {
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AudioMonitorActive() {
|
||||||
|
return window['go']['main']['App']['AudioMonitorActive']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioStartMonitor() {
|
||||||
|
return window['go']['main']['App']['AudioStartMonitor']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioStartTX() {
|
||||||
|
return window['go']['main']['App']['AudioStartTX']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioStopMonitor() {
|
||||||
|
return window['go']['main']['App']['AudioStopMonitor']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioStopTX() {
|
||||||
|
return window['go']['main']['App']['AudioStopTX']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioTXActive() {
|
||||||
|
return window['go']['main']['App']['AudioTXActive']();
|
||||||
|
}
|
||||||
|
|
||||||
export function AwardCellQSOs(arg1, arg2, arg3) {
|
export function AwardCellQSOs(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
@@ -50,6 +82,10 @@ export function AwardRefsForQSOs(arg1) {
|
|||||||
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
|
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AwardsFolder() {
|
||||||
|
return window['go']['main']['App']['AwardsFolder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function BrowseExecutable() {
|
export function BrowseExecutable() {
|
||||||
return window['go']['main']['App']['BrowseExecutable']();
|
return window['go']['main']['App']['BrowseExecutable']();
|
||||||
}
|
}
|
||||||
@@ -202,6 +238,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']();
|
||||||
}
|
}
|
||||||
@@ -214,10 +254,18 @@ export function DownloadConfirmations(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DownloadLoTWUsers() {
|
||||||
|
return window['go']['main']['App']['DownloadLoTWUsers']();
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -230,6 +278,10 @@ export function ExportADIFSelected(arg1, arg2, arg3) {
|
|||||||
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
|
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ExportAward(arg1) {
|
||||||
|
return window['go']['main']['App']['ExportAward'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExportAwards() {
|
export function ExportAwards() {
|
||||||
return window['go']['main']['App']['ExportAwards']();
|
return window['go']['main']['App']['ExportAwards']();
|
||||||
}
|
}
|
||||||
@@ -342,6 +394,10 @@ export function FlexSetMic(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetMic'](arg1);
|
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetMicProfile(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetMicProfile'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetMon(arg1) {
|
export function FlexSetMon(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetMon'](arg1);
|
return window['go']['main']['App']['FlexSetMon'](arg1);
|
||||||
}
|
}
|
||||||
@@ -382,6 +438,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);
|
||||||
}
|
}
|
||||||
@@ -398,6 +462,10 @@ export function FlexSetTXAntenna(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetTXFilter(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['FlexSetTXFilter'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexSetTXSlice(arg1) {
|
export function FlexSetTXSlice(arg1) {
|
||||||
return window['go']['main']['App']['FlexSetTXSlice'](arg1);
|
return window['go']['main']['App']['FlexSetTXSlice'](arg1);
|
||||||
}
|
}
|
||||||
@@ -418,6 +486,22 @@ export function FlexSetVoxLevel(arg1) {
|
|||||||
return window['go']['main']['App']['FlexSetVoxLevel'](arg1);
|
return window['go']['main']['App']['FlexSetVoxLevel'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexSetWNB(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetWNB'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetWNBLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetWNBLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetXIT(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetXIT'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetXITFreq(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function FlexTune(arg1) {
|
export function FlexTune(arg1) {
|
||||||
return window['go']['main']['App']['FlexTune'](arg1);
|
return window['go']['main']['App']['FlexTune'](arg1);
|
||||||
}
|
}
|
||||||
@@ -466,6 +550,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']();
|
||||||
}
|
}
|
||||||
@@ -486,6 +574,10 @@ export function GetCWDecoderPitch() {
|
|||||||
return window['go']['main']['App']['GetCWDecoderPitch']();
|
return window['go']['main']['App']['GetCWDecoderPitch']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetCatalogCodes() {
|
||||||
|
return window['go']['main']['App']['GetCatalogCodes']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChatHistory(arg1) {
|
export function GetChatHistory(arg1) {
|
||||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -502,6 +594,10 @@ export function GetClusterStatus() {
|
|||||||
return window['go']['main']['App']['GetClusterStatus']();
|
return window['go']['main']['App']['GetClusterStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetContestRuns() {
|
||||||
|
return window['go']['main']['App']['GetContestRuns']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetCtyDatInfo() {
|
export function GetCtyDatInfo() {
|
||||||
return window['go']['main']['App']['GetCtyDatInfo']();
|
return window['go']['main']['App']['GetCtyDatInfo']();
|
||||||
}
|
}
|
||||||
@@ -558,10 +654,18 @@ export function GetLiveStatusEnabled() {
|
|||||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLoTWUsersStatus() {
|
||||||
|
return window['go']['main']['App']['GetLoTWUsersStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLogFilePath() {
|
export function GetLogFilePath() {
|
||||||
return window['go']['main']['App']['GetLogFilePath']();
|
return window['go']['main']['App']['GetLogFilePath']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLogStats(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['main']['App']['GetLogStats'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLogbookRevision() {
|
export function GetLogbookRevision() {
|
||||||
return window['go']['main']['App']['GetLogbookRevision']();
|
return window['go']['main']['App']['GetLogbookRevision']();
|
||||||
}
|
}
|
||||||
@@ -574,6 +678,10 @@ export function GetMySQLSettings() {
|
|||||||
return window['go']['main']['App']['GetMySQLSettings']();
|
return window['go']['main']['App']['GetMySQLSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetOfflineStatus() {
|
||||||
|
return window['go']['main']['App']['GetOfflineStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetOnlineOperators() {
|
export function GetOnlineOperators() {
|
||||||
return window['go']['main']['App']['GetOnlineOperators']();
|
return window['go']['main']['App']['GetOnlineOperators']();
|
||||||
}
|
}
|
||||||
@@ -590,6 +698,10 @@ export function GetPOTAToken() {
|
|||||||
return window['go']['main']['App']['GetPOTAToken']();
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPendingQSOs() {
|
||||||
|
return window['go']['main']['App']['GetPendingQSOs']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetQSLDefaults() {
|
export function GetQSLDefaults() {
|
||||||
return window['go']['main']['App']['GetQSLDefaults']();
|
return window['go']['main']['App']['GetQSLDefaults']();
|
||||||
}
|
}
|
||||||
@@ -610,6 +722,10 @@ export function GetSecretStatus() {
|
|||||||
return window['go']['main']['App']['GetSecretStatus']();
|
return window['go']['main']['App']['GetSecretStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSolarData() {
|
||||||
|
return window['go']['main']['App']['GetSolarData']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetStartupStatus() {
|
export function GetStartupStatus() {
|
||||||
return window['go']['main']['App']['GetStartupStatus']();
|
return window['go']['main']['App']['GetStartupStatus']();
|
||||||
}
|
}
|
||||||
@@ -830,6 +946,10 @@ export function ImportAwards() {
|
|||||||
return window['go']['main']['App']['ImportAwards']();
|
return window['go']['main']['App']['ImportAwards']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function InspectAwardImport() {
|
||||||
|
return window['go']['main']['App']['InspectAwardImport']();
|
||||||
|
}
|
||||||
|
|
||||||
export function LaunchAutostartProgram(arg1) {
|
export function LaunchAutostartProgram(arg1) {
|
||||||
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
return window['go']['main']['App']['LaunchAutostartProgram'](arg1);
|
||||||
}
|
}
|
||||||
@@ -894,6 +1014,10 @@ export function ListUDPIntegrations() {
|
|||||||
return window['go']['main']['App']['ListUDPIntegrations']();
|
return window['go']['main']['App']['ListUDPIntegrations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LoTWUserInfo(arg1) {
|
||||||
|
return window['go']['main']['App']['LoTWUserInfo'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function LogUDPLoggedADIF(arg1) {
|
export function LogUDPLoggedADIF(arg1) {
|
||||||
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
return window['go']['main']['App']['LogUDPLoggedADIF'](arg1);
|
||||||
}
|
}
|
||||||
@@ -978,6 +1102,10 @@ export function OpenADIFFile() {
|
|||||||
return window['go']['main']['App']['OpenADIFFile']();
|
return window['go']['main']['App']['OpenADIFFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OpenAwardsFolder() {
|
||||||
|
return window['go']['main']['App']['OpenAwardsFolder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function OpenDatabase(arg1) {
|
export function OpenDatabase(arg1) {
|
||||||
return window['go']['main']['App']['OpenDatabase'](arg1);
|
return window['go']['main']['App']['OpenDatabase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1106,6 +1234,10 @@ export function RefreshCtyDat() {
|
|||||||
return window['go']['main']['App']['RefreshCtyDat']();
|
return window['go']['main']['App']['RefreshCtyDat']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RefreshSolar() {
|
||||||
|
return window['go']['main']['App']['RefreshSolar']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ReloadUDPIntegrations() {
|
export function ReloadUDPIntegrations() {
|
||||||
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
return window['go']['main']['App']['ReloadUDPIntegrations']();
|
||||||
}
|
}
|
||||||
@@ -1146,6 +1278,10 @@ export function RestartQSORecorder() {
|
|||||||
return window['go']['main']['App']['RestartQSORecorder']();
|
return window['go']['main']['App']['RestartQSORecorder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RetryOfflineSync() {
|
||||||
|
return window['go']['main']['App']['RetryOfflineSync']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RotatorGoTo(arg1, arg2) {
|
export function RotatorGoTo(arg1, arg2) {
|
||||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export namespace antgenius {
|
|||||||
export class Antenna {
|
export class Antenna {
|
||||||
index: number;
|
index: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
bands: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Antenna(source);
|
return new Antenna(source);
|
||||||
@@ -126,6 +127,7 @@ export namespace antgenius {
|
|||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.index = source["index"];
|
this.index = source["index"];
|
||||||
this.name = source["name"];
|
this.name = source["name"];
|
||||||
|
this.bands = source["bands"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class Status {
|
export class Status {
|
||||||
@@ -258,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[];
|
||||||
@@ -272,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);
|
||||||
@@ -298,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"];
|
||||||
@@ -312,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 {
|
||||||
@@ -363,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;
|
||||||
@@ -502,6 +613,7 @@ export namespace cat {
|
|||||||
src?: string;
|
src?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
|
slice: number;
|
||||||
value: number;
|
value: number;
|
||||||
lo: number;
|
lo: number;
|
||||||
hi: number;
|
hi: number;
|
||||||
@@ -516,6 +628,7 @@ export namespace cat {
|
|||||||
this.src = source["src"];
|
this.src = source["src"];
|
||||||
this.name = source["name"];
|
this.name = source["name"];
|
||||||
this.unit = source["unit"];
|
this.unit = source["unit"];
|
||||||
|
this.slice = source["slice"];
|
||||||
this.value = source["value"];
|
this.value = source["value"];
|
||||||
this.lo = source["lo"];
|
this.lo = source["lo"];
|
||||||
this.hi = source["hi"];
|
this.hi = source["hi"];
|
||||||
@@ -583,6 +696,10 @@ export namespace cat {
|
|||||||
mon: boolean;
|
mon: boolean;
|
||||||
mon_level: number;
|
mon_level: number;
|
||||||
mic_level: number;
|
mic_level: number;
|
||||||
|
tx_filter_low: number;
|
||||||
|
tx_filter_high: number;
|
||||||
|
mic_profile?: string;
|
||||||
|
mic_profiles?: string[];
|
||||||
atu_status?: string;
|
atu_status?: string;
|
||||||
atu_memories: boolean;
|
atu_memories: boolean;
|
||||||
rx_avail: boolean;
|
rx_avail: boolean;
|
||||||
@@ -603,6 +720,12 @@ export namespace cat {
|
|||||||
nr_level: number;
|
nr_level: number;
|
||||||
anf: boolean;
|
anf: boolean;
|
||||||
anf_level: number;
|
anf_level: number;
|
||||||
|
wnb: boolean;
|
||||||
|
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;
|
||||||
@@ -640,6 +763,10 @@ export namespace cat {
|
|||||||
this.mon = source["mon"];
|
this.mon = source["mon"];
|
||||||
this.mon_level = source["mon_level"];
|
this.mon_level = source["mon_level"];
|
||||||
this.mic_level = source["mic_level"];
|
this.mic_level = source["mic_level"];
|
||||||
|
this.tx_filter_low = source["tx_filter_low"];
|
||||||
|
this.tx_filter_high = source["tx_filter_high"];
|
||||||
|
this.mic_profile = source["mic_profile"];
|
||||||
|
this.mic_profiles = source["mic_profiles"];
|
||||||
this.atu_status = source["atu_status"];
|
this.atu_status = source["atu_status"];
|
||||||
this.atu_memories = source["atu_memories"];
|
this.atu_memories = source["atu_memories"];
|
||||||
this.rx_avail = source["rx_avail"];
|
this.rx_avail = source["rx_avail"];
|
||||||
@@ -660,6 +787,12 @@ export namespace cat {
|
|||||||
this.nr_level = source["nr_level"];
|
this.nr_level = source["nr_level"];
|
||||||
this.anf = source["anf"];
|
this.anf = source["anf"];
|
||||||
this.anf_level = source["anf_level"];
|
this.anf_level = source["anf_level"];
|
||||||
|
this.wnb = source["wnb"];
|
||||||
|
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"];
|
||||||
@@ -1140,6 +1273,27 @@ export namespace lookup {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace lotwusers {
|
||||||
|
|
||||||
|
export class Info {
|
||||||
|
is_user: boolean;
|
||||||
|
last_upload?: string;
|
||||||
|
days_ago: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Info(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.is_user = source["is_user"];
|
||||||
|
this.last_upload = source["last_upload"];
|
||||||
|
this.days_ago = source["days_ago"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace main {
|
export namespace main {
|
||||||
|
|
||||||
export class AntGeniusSettings {
|
export class AntGeniusSettings {
|
||||||
@@ -1230,6 +1384,95 @@ 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 {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
references: number;
|
||||||
|
exists: boolean;
|
||||||
|
mine_name: string;
|
||||||
|
mine_refs: number;
|
||||||
|
protected: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AwardImportPreviewEntry(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.code = source["code"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.references = source["references"];
|
||||||
|
this.exists = source["exists"];
|
||||||
|
this.mine_name = source["mine_name"];
|
||||||
|
this.mine_refs = source["mine_refs"];
|
||||||
|
this.protected = source["protected"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class AwardImportPreview {
|
||||||
|
path: string;
|
||||||
|
awards: AwardImportPreviewEntry[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AwardImportPreview(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.path = source["path"];
|
||||||
|
this.awards = this.convertValues(source["awards"], AwardImportPreviewEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 AwardImportResult {
|
export class AwardImportResult {
|
||||||
awards: number;
|
awards: number;
|
||||||
references: number;
|
references: number;
|
||||||
@@ -1314,6 +1557,24 @@ 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 BackupSettings {
|
export class BackupSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
folder: string;
|
folder: string;
|
||||||
@@ -1343,12 +1604,15 @@ export namespace main {
|
|||||||
flex_host: string;
|
flex_host: string;
|
||||||
flex_port: number;
|
flex_port: number;
|
||||||
flex_spots: boolean;
|
flex_spots: boolean;
|
||||||
|
flex_decode_spots: boolean;
|
||||||
|
flex_decode_secs: number;
|
||||||
icom_port: string;
|
icom_port: string;
|
||||||
icom_baud: number;
|
icom_baud: number;
|
||||||
icom_addr: number;
|
icom_addr: number;
|
||||||
icom_net_host: string;
|
icom_net_host: string;
|
||||||
icom_net_user: string;
|
icom_net_user: string;
|
||||||
icom_net_pass: string;
|
icom_net_pass: string;
|
||||||
|
icom_net_audio: boolean;
|
||||||
tci_host: string;
|
tci_host: string;
|
||||||
tci_port: number;
|
tci_port: number;
|
||||||
tci_spots: boolean;
|
tci_spots: boolean;
|
||||||
@@ -1368,12 +1632,15 @@ export namespace main {
|
|||||||
this.flex_host = source["flex_host"];
|
this.flex_host = source["flex_host"];
|
||||||
this.flex_port = source["flex_port"];
|
this.flex_port = source["flex_port"];
|
||||||
this.flex_spots = source["flex_spots"];
|
this.flex_spots = source["flex_spots"];
|
||||||
|
this.flex_decode_spots = source["flex_decode_spots"];
|
||||||
|
this.flex_decode_secs = source["flex_decode_secs"];
|
||||||
this.icom_port = source["icom_port"];
|
this.icom_port = source["icom_port"];
|
||||||
this.icom_baud = source["icom_baud"];
|
this.icom_baud = source["icom_baud"];
|
||||||
this.icom_addr = source["icom_addr"];
|
this.icom_addr = source["icom_addr"];
|
||||||
this.icom_net_host = source["icom_net_host"];
|
this.icom_net_host = source["icom_net_host"];
|
||||||
this.icom_net_user = source["icom_net_user"];
|
this.icom_net_user = source["icom_net_user"];
|
||||||
this.icom_net_pass = source["icom_net_pass"];
|
this.icom_net_pass = source["icom_net_pass"];
|
||||||
|
this.icom_net_audio = source["icom_net_audio"];
|
||||||
this.tci_host = source["tci_host"];
|
this.tci_host = source["tci_host"];
|
||||||
this.tci_port = source["tci_port"];
|
this.tci_port = source["tci_port"];
|
||||||
this.tci_spots = source["tci_spots"];
|
this.tci_spots = source["tci_spots"];
|
||||||
@@ -1722,6 +1989,20 @@ export namespace main {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class LoTWUsersStatus {
|
||||||
|
count: number;
|
||||||
|
updated?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new LoTWUsersStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.count = source["count"];
|
||||||
|
this.updated = source["updated"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class LookupSettings {
|
export class LookupSettings {
|
||||||
qrz_user: string;
|
qrz_user: string;
|
||||||
qrz_password: string;
|
qrz_password: string;
|
||||||
@@ -1771,6 +2052,22 @@ export namespace main {
|
|||||||
this.database = source["database"];
|
this.database = source["database"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class OfflineStatus {
|
||||||
|
offline: boolean;
|
||||||
|
pending: number;
|
||||||
|
path: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new OfflineStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.offline = source["offline"];
|
||||||
|
this.pending = source["pending"];
|
||||||
|
this.path = source["path"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class PGXLSettings {
|
export class PGXLSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -2168,8 +2465,12 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
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;
|
||||||
|
|
||||||
@@ -2180,8 +2481,12 @@ 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"];
|
||||||
}
|
}
|
||||||
@@ -2849,6 +3154,20 @@ export namespace qso {
|
|||||||
this.status = source["status"];
|
this.status = source["status"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class Bucket {
|
||||||
|
key: string;
|
||||||
|
count: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Bucket(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.key = source["key"];
|
||||||
|
this.count = source["count"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class Condition {
|
export class Condition {
|
||||||
field: string;
|
field: string;
|
||||||
op: string;
|
op: string;
|
||||||
@@ -2865,6 +3184,42 @@ export namespace qso {
|
|||||||
this.value = source["value"];
|
this.value = source["value"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ContestRun {
|
||||||
|
id: string;
|
||||||
|
year: number;
|
||||||
|
count: number;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ContestRun(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.year = source["year"];
|
||||||
|
this.count = source["count"];
|
||||||
|
this.start = source["start"];
|
||||||
|
this.end = source["end"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Gap {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
minutes: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Gap(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.start = source["start"];
|
||||||
|
this.end = source["end"];
|
||||||
|
this.minutes = source["minutes"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ListFilter {
|
export class ListFilter {
|
||||||
callsign?: string;
|
callsign?: string;
|
||||||
band?: string;
|
band?: string;
|
||||||
@@ -3213,6 +3568,98 @@ export namespace qso {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class Stats {
|
||||||
|
total: number;
|
||||||
|
unique_calls: number;
|
||||||
|
entities: number;
|
||||||
|
continents: number;
|
||||||
|
first_qso: string;
|
||||||
|
last_qso: string;
|
||||||
|
confirmed_lotw: number;
|
||||||
|
confirmed_eqsl: number;
|
||||||
|
confirmed_qsl: number;
|
||||||
|
confirmed_any: number;
|
||||||
|
by_mode: Bucket[];
|
||||||
|
by_band: Bucket[];
|
||||||
|
by_operator: Bucket[];
|
||||||
|
by_station: Bucket[];
|
||||||
|
by_continent: Bucket[];
|
||||||
|
top_entities: Bucket[];
|
||||||
|
by_year: Bucket[];
|
||||||
|
by_month: Bucket[];
|
||||||
|
window_start: string;
|
||||||
|
window_end: string;
|
||||||
|
window_hours: number;
|
||||||
|
avg_per_hour: number;
|
||||||
|
avg_per_active: number;
|
||||||
|
on_air_minutes: number;
|
||||||
|
off_air_minutes: number;
|
||||||
|
peak_hour_key: string;
|
||||||
|
peak_hour_count: number;
|
||||||
|
best_60: number;
|
||||||
|
gaps: Gap[];
|
||||||
|
rate: Bucket[];
|
||||||
|
rate_ops: string[];
|
||||||
|
rate_by_op: number[][];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Stats(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.total = source["total"];
|
||||||
|
this.unique_calls = source["unique_calls"];
|
||||||
|
this.entities = source["entities"];
|
||||||
|
this.continents = source["continents"];
|
||||||
|
this.first_qso = source["first_qso"];
|
||||||
|
this.last_qso = source["last_qso"];
|
||||||
|
this.confirmed_lotw = source["confirmed_lotw"];
|
||||||
|
this.confirmed_eqsl = source["confirmed_eqsl"];
|
||||||
|
this.confirmed_qsl = source["confirmed_qsl"];
|
||||||
|
this.confirmed_any = source["confirmed_any"];
|
||||||
|
this.by_mode = this.convertValues(source["by_mode"], Bucket);
|
||||||
|
this.by_band = this.convertValues(source["by_band"], Bucket);
|
||||||
|
this.by_operator = this.convertValues(source["by_operator"], Bucket);
|
||||||
|
this.by_station = this.convertValues(source["by_station"], Bucket);
|
||||||
|
this.by_continent = this.convertValues(source["by_continent"], Bucket);
|
||||||
|
this.top_entities = this.convertValues(source["top_entities"], Bucket);
|
||||||
|
this.by_year = this.convertValues(source["by_year"], Bucket);
|
||||||
|
this.by_month = this.convertValues(source["by_month"], Bucket);
|
||||||
|
this.window_start = source["window_start"];
|
||||||
|
this.window_end = source["window_end"];
|
||||||
|
this.window_hours = source["window_hours"];
|
||||||
|
this.avg_per_hour = source["avg_per_hour"];
|
||||||
|
this.avg_per_active = source["avg_per_active"];
|
||||||
|
this.on_air_minutes = source["on_air_minutes"];
|
||||||
|
this.off_air_minutes = source["off_air_minutes"];
|
||||||
|
this.peak_hour_key = source["peak_hour_key"];
|
||||||
|
this.peak_hour_count = source["peak_hour_count"];
|
||||||
|
this.best_60 = source["best_60"];
|
||||||
|
this.gaps = this.convertValues(source["gaps"], Gap);
|
||||||
|
this.rate = this.convertValues(source["rate"], Bucket);
|
||||||
|
this.rate_ops = source["rate_ops"];
|
||||||
|
this.rate_by_op = source["rate_by_op"];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 WorkedBefore {
|
export class WorkedBefore {
|
||||||
callsign: string;
|
callsign: string;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -3282,6 +3729,66 @@ export namespace qso {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace solar {
|
||||||
|
|
||||||
|
export class Data {
|
||||||
|
sfi: string;
|
||||||
|
ssn: string;
|
||||||
|
a_index: string;
|
||||||
|
k_index: string;
|
||||||
|
xray: string;
|
||||||
|
geomag_field: string;
|
||||||
|
aurora: string;
|
||||||
|
muf: string;
|
||||||
|
updated: string;
|
||||||
|
source: string;
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
// Go type: time
|
||||||
|
fetched_at: any;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Data(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.sfi = source["sfi"];
|
||||||
|
this.ssn = source["ssn"];
|
||||||
|
this.a_index = source["a_index"];
|
||||||
|
this.k_index = source["k_index"];
|
||||||
|
this.xray = source["xray"];
|
||||||
|
this.geomag_field = source["geomag_field"];
|
||||||
|
this.aurora = source["aurora"];
|
||||||
|
this.muf = source["muf"];
|
||||||
|
this.updated = source["updated"];
|
||||||
|
this.source = source["source"];
|
||||||
|
this.ok = source["ok"];
|
||||||
|
this.error = source["error"];
|
||||||
|
this.fetched_at = this.convertValues(source["fetched_at"], null);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 namespace udp {
|
export namespace udp {
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
|||||||
@@ -120,6 +120,18 @@ func SingleRecordADIF(q qso.QSO) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
|
||||||
|
// so it can be written out and read back with nothing dropped. Used by the
|
||||||
|
// offline queue: a QSO parked in the safety file must come back identical
|
||||||
|
// (extras carry its queue id, awards refs, ADIF 3.1.7 leftovers…).
|
||||||
|
func FullRecordADIF(q qso.QSO) string {
|
||||||
|
var b strings.Builder
|
||||||
|
bw := bufio.NewWriter(&b)
|
||||||
|
writeRecord(bw, q, true)
|
||||||
|
bw.Flush()
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
||||||
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
||||||
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
||||||
|
|||||||
@@ -32,9 +32,13 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Antenna is one configured antenna (index + name as stored on the device).
|
// Antenna is one configured antenna (index + name as stored on the device).
|
||||||
|
// Bands is the device's band bitmask for the antenna (which bands it covers);
|
||||||
|
// 0 = unknown/all. The UI uses it to show only band-appropriate antennas, like
|
||||||
|
// the native 4O3A app.
|
||||||
type Antenna struct {
|
type Antenna struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Bands int `json:"bands"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status is the snapshot the UI renders.
|
// Status is the snapshot the UI renders.
|
||||||
@@ -65,6 +69,8 @@ type Client struct {
|
|||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
status Status
|
status Status
|
||||||
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
||||||
|
antBands map[int]int // index → band bitmask (which bands the antenna covers)
|
||||||
|
antRawN int // one-shot: how many raw antenna lines we've logged
|
||||||
|
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
running bool
|
running bool
|
||||||
@@ -80,6 +86,7 @@ func New(host string, port int, password string) *Client {
|
|||||||
password: strings.TrimSpace(password),
|
password: strings.TrimSpace(password),
|
||||||
stop: make(chan struct{}),
|
stop: make(chan struct{}),
|
||||||
antennas: map[int]string{},
|
antennas: map[int]string{},
|
||||||
|
antBands: map[int]int{},
|
||||||
status: Status{Host: host},
|
status: Status{Host: host},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,13 +363,24 @@ func (c *Client) parseAntenna(msg string) {
|
|||||||
// The device stores spaces as underscores in names.
|
// The device stores spaces as underscores in names.
|
||||||
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
|
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
|
||||||
}
|
}
|
||||||
|
// Band bitmask: which bands this antenna is configured for. The device sends
|
||||||
|
// it as "band=<mask>" (a bitmask). Kept so the UI can show only band-relevant
|
||||||
|
// antennas (like the native app). Log the first few raw antenna lines so the
|
||||||
|
// exact field name + bit values can be confirmed on real hardware.
|
||||||
|
bands := kvInt(msg, "band")
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
|
if c.antRawN < 8 {
|
||||||
|
c.antRawN++
|
||||||
|
applog.Printf("antgenius: antenna raw #%d: %q (parsed band mask=%d/0x%X)", c.antRawN, msg, bands, bands)
|
||||||
|
}
|
||||||
if name != "" && !isPlaceholderName(name) {
|
if name != "" && !isPlaceholderName(name) {
|
||||||
c.antennas[id] = name
|
c.antennas[id] = name
|
||||||
|
c.antBands[id] = bands
|
||||||
} else {
|
} else {
|
||||||
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
|
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
|
||||||
|
delete(c.antBands, id)
|
||||||
}
|
}
|
||||||
c.status.Antennas = sortedAntennas(c.antennas)
|
c.status.Antennas = sortedAntennas(c.antennas, c.antBands)
|
||||||
c.status.Connected = true
|
c.status.Connected = true
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -431,10 +449,10 @@ func isPlaceholderName(name string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sortedAntennas(m map[int]string) []Antenna {
|
func sortedAntennas(m map[int]string, bands map[int]int) []Antenna {
|
||||||
out := make([]Antenna, 0, len(m))
|
out := make([]Antenna, 0, len(m))
|
||||||
for idx, name := range m {
|
for idx, name := range m {
|
||||||
out = append(out, Antenna{Index: idx, Name: name})
|
out = append(out, Antenna{Index: idx, Name: name, Bands: bands[idx]})
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
|
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Codec converts between the wire payload of a network audio stream (Icom 50003)
|
||||||
|
// and OpsLog's internal PCM (16 kHz mono 16-bit little-endian — the format the
|
||||||
|
// capture/render engine and the pcmRing use). It exists so the transport code
|
||||||
|
// (icomaudio.go) never hard-codes a format: today PCM is an identity passthrough;
|
||||||
|
// tomorrow an Opus codec implements the same two methods and drops in unchanged.
|
||||||
|
// This mirrors how civTransport abstracts the CAT byte stream from its transport.
|
||||||
|
type Codec interface {
|
||||||
|
// Name is a short label for logs/UI.
|
||||||
|
Name() string
|
||||||
|
// Decode turns one received audio payload into internal PCM. The returned
|
||||||
|
// slice is freshly allocated (the caller may retain it).
|
||||||
|
Decode(payload []byte) ([]byte, error)
|
||||||
|
// Encode turns internal PCM into a payload to transmit.
|
||||||
|
Encode(pcm []byte) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pcm16Codec is the uncompressed 16-bit-PCM codec — the Icom "uncompressed"
|
||||||
|
// audio mode (rxcodec/txcodec in the conninfo). Icom sends little-endian 16-bit
|
||||||
|
// mono samples, which is byte-for-byte OpsLog's internal format, so decode/encode
|
||||||
|
// are copies. It is the Phase-4/5 default: zero-dependency, lossless, ideal on a
|
||||||
|
// LAN. Opus (for WAN/internet bandwidth) becomes another Codec later.
|
||||||
|
//
|
||||||
|
// NOTE: rate conversion is deliberately NOT this layer's job. The Icom RX audio
|
||||||
|
// is 16 kHz = our internal rate, so RX needs none. The rig's TX side may run at a
|
||||||
|
// different rate (the captured conninfo showed 8 kHz) — Phase 5 will resample in
|
||||||
|
// the TX path before Encode; keeping the codec rate-agnostic keeps that concern
|
||||||
|
// in one place.
|
||||||
|
type pcm16Codec struct{}
|
||||||
|
|
||||||
|
// NewPCM16Codec returns the uncompressed 16-bit PCM codec.
|
||||||
|
func NewPCM16Codec() Codec { return pcm16Codec{} }
|
||||||
|
|
||||||
|
func (pcm16Codec) Name() string { return "pcm16" }
|
||||||
|
|
||||||
|
func (pcm16Codec) Decode(payload []byte) ([]byte, error) {
|
||||||
|
// Icom PCM payloads are whole 16-bit samples; an odd length means a truncated
|
||||||
|
// packet — trim the stray byte rather than emit a half-sample click.
|
||||||
|
n := len(payload) &^ 1
|
||||||
|
if n != len(payload) {
|
||||||
|
if n == 0 {
|
||||||
|
return nil, fmt.Errorf("pcm16: payload too short (%d bytes)", len(payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]byte, n)
|
||||||
|
copy(out, payload[:n])
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pcm16Codec) Encode(pcm []byte) ([]byte, error) {
|
||||||
|
out := make([]byte, len(pcm))
|
||||||
|
copy(out, pcm)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPCM16CodecRoundTrip(t *testing.T) {
|
||||||
|
c := NewPCM16Codec()
|
||||||
|
in := []byte{0x01, 0x02, 0x03, 0x04, 0xff, 0x7f}
|
||||||
|
enc, err := c.Encode(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode: %v", err)
|
||||||
|
}
|
||||||
|
dec, err := c.Decode(enc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if string(dec) != string(in) {
|
||||||
|
t.Fatalf("round-trip mismatch: got % X want % X", dec, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPCM16CodecTrimsOddByte(t *testing.T) {
|
||||||
|
c := NewPCM16Codec()
|
||||||
|
dec, err := c.Decode([]byte{0x10, 0x20, 0x30}) // 3 bytes = 1 sample + stray
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(dec) != 2 {
|
||||||
|
t.Fatalf("expected the stray byte trimmed to 2, got %d", len(dec))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPCM16CodecRejectsSingleByte(t *testing.T) {
|
||||||
|
c := NewPCM16Codec()
|
||||||
|
if _, err := c.Decode([]byte{0x10}); err == nil {
|
||||||
|
t.Fatalf("expected an error for a sub-sample payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ package audio
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
@@ -269,3 +270,151 @@ func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct
|
|||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pcmRing is a thread-safe, latency-bounded FIFO of PCM bytes feeding a live
|
||||||
|
// render stream. Producers (a USB-codec capture, or a decoded network audio
|
||||||
|
// stream) Push freshly-arrived samples; the render loop Pulls. It is the shared
|
||||||
|
// hand-off point between "where the audio comes from" (USB device / UDP 50003)
|
||||||
|
// and "where it's heard" (any WASAPI output) — so the transport can be swapped
|
||||||
|
// without touching the render side, mirroring the civTransport split on the CAT
|
||||||
|
// side. On overflow the oldest audio is dropped to keep latency bounded; on
|
||||||
|
// underrun Pull simply returns short and the render loop pads with silence.
|
||||||
|
type pcmRing struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf []byte
|
||||||
|
max int // hard cap in bytes (drops oldest beyond this → bounded latency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPCMRing makes a ring whose backlog is capped at maxBytes. Size it from the
|
||||||
|
// acceptable latency: bytesPerSec (=32000) worth ≈ 1 s.
|
||||||
|
func newPCMRing(maxBytes int) *pcmRing {
|
||||||
|
if maxBytes <= 0 {
|
||||||
|
maxBytes = bytesPerSec // 1 s default
|
||||||
|
}
|
||||||
|
return &pcmRing{max: maxBytes}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push appends samples, dropping the oldest audio if the backlog would exceed
|
||||||
|
// the cap (a slow/absent consumer never makes the producer block or grow without
|
||||||
|
// bound). A short glitch beats runaway latency for live monitoring.
|
||||||
|
func (r *pcmRing) Push(p []byte) {
|
||||||
|
if len(p) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.buf = append(r.buf, p...)
|
||||||
|
if len(r.buf) > r.max {
|
||||||
|
drop := len(r.buf) - r.max
|
||||||
|
r.buf = append(r.buf[:0], r.buf[drop:]...)
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pull removes and returns up to maxBytes of queued PCM (a private copy), or nil
|
||||||
|
// when empty. The render loop pads any shortfall with silence.
|
||||||
|
func (r *pcmRing) pull(maxBytes int) []byte {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if len(r.buf) == 0 || maxBytes <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n := maxBytes
|
||||||
|
if n > len(r.buf) {
|
||||||
|
n = len(r.buf)
|
||||||
|
}
|
||||||
|
out := make([]byte, n)
|
||||||
|
copy(out, r.buf[:n])
|
||||||
|
r.buf = append(r.buf[:0], r.buf[n:]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderStream continuously renders PCM pulled from src to a device until stop
|
||||||
|
// closes — the streaming counterpart to playPCM's fixed buffer. On underrun it
|
||||||
|
// writes silence rather than glitching, keeping the WASAPI clock steady so live
|
||||||
|
// monitor audio flows smoothly even when the source stalls briefly. Runs on a
|
||||||
|
// COM-initialised, OS-locked thread.
|
||||||
|
func renderStream(deviceID string, rate, ch, bits int, stop <-chan struct{}, src *pcmRing) error {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
defer runtime.UnlockOSThread()
|
||||||
|
if err := coInit(); err != nil {
|
||||||
|
return fmt.Errorf("CoInitialize: %w", err)
|
||||||
|
}
|
||||||
|
defer ole.CoUninitialize()
|
||||||
|
|
||||||
|
dev, err := openDevice(wca.ERender, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dev.Release()
|
||||||
|
|
||||||
|
var ac *wca.IAudioClient
|
||||||
|
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
|
||||||
|
return fmt.Errorf("activate render: %w", err)
|
||||||
|
}
|
||||||
|
defer ac.Release()
|
||||||
|
|
||||||
|
frameBytes := ch * bits / 8
|
||||||
|
if frameBytes <= 0 {
|
||||||
|
return fmt.Errorf("bad audio format")
|
||||||
|
}
|
||||||
|
wfx := &wca.WAVEFORMATEX{
|
||||||
|
WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate),
|
||||||
|
NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes),
|
||||||
|
WBitsPerSample: uint16(bits), CbSize: 0,
|
||||||
|
}
|
||||||
|
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
|
||||||
|
wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil {
|
||||||
|
return fmt.Errorf("initialize render: %w", err)
|
||||||
|
}
|
||||||
|
var bufFrames uint32
|
||||||
|
if err := ac.GetBufferSize(&bufFrames); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var arc *wca.IAudioRenderClient
|
||||||
|
if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil {
|
||||||
|
return fmt.Errorf("get render service: %w", err)
|
||||||
|
}
|
||||||
|
defer arc.Release()
|
||||||
|
|
||||||
|
// feed fills up to `frames` render frames: as much real audio as the ring
|
||||||
|
// has, the remainder silence (so the buffer stays full and the clock steady).
|
||||||
|
feed := func(frames int) error {
|
||||||
|
if frames <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var data *byte
|
||||||
|
if err := arc.GetBuffer(uint32(frames), &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst := unsafe.Slice(data, frames*frameBytes)
|
||||||
|
got := src.pull(frames * frameBytes)
|
||||||
|
n := copy(dst, got)
|
||||||
|
for i := n; i < len(dst); i++ {
|
||||||
|
dst[i] = 0 // silence-fill the shortfall
|
||||||
|
}
|
||||||
|
arc.ReleaseBuffer(uint32(frames), 0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := feed(int(bufFrames)); err != nil { // pre-fill to avoid a start glitch
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ac.Start(); err != nil {
|
||||||
|
return fmt.Errorf("start render: %w", err)
|
||||||
|
}
|
||||||
|
defer ac.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
var padding uint32
|
||||||
|
ac.GetCurrentPadding(&padding)
|
||||||
|
if err := feed(int(bufFrames - padding)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(8 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ type Manager struct {
|
|||||||
recStop chan struct{}
|
recStop chan struct{}
|
||||||
recDone chan recResult
|
recDone chan recResult
|
||||||
playStop chan struct{}
|
playStop chan struct{}
|
||||||
|
monStop chan struct{} // RX monitor passthrough (capture → render)
|
||||||
|
monRing *pcmRing // live audio hand-off, also fed by the network stream
|
||||||
|
txStop chan struct{} // TX audio passthrough (mic → rig)
|
||||||
onChange func() // fired on any record/playback state transition
|
onChange func() // fired on any record/playback state transition
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,3 +138,130 @@ func (m *Manager) StopPlayback() {
|
|||||||
m.notify()
|
m.notify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- RX audio monitor (Phase 2: USB codec passthrough) --------------------
|
||||||
|
//
|
||||||
|
// StartMonitor pipes live RX audio from inputDev (e.g. the rig's "USB Audio
|
||||||
|
// CODEC" capture endpoint) to outputDev (your speakers/headset) through a
|
||||||
|
// latency-bounded ring, so you HEAR the radio inside OpsLog. The very same ring
|
||||||
|
// is later fed by the network 50003 stream instead of a USB capture — the render
|
||||||
|
// half is transport-agnostic. inputDev "" = system default capture.
|
||||||
|
func (m *Manager) StartMonitor(inputDev, outputDev string) error {
|
||||||
|
return m.startMonitor(inputDev, outputDev, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartMonitorSink starts ONLY the render side (no USB capture) so an external
|
||||||
|
// producer — the network 50003 stream — can feed decoded RX PCM via
|
||||||
|
// PushMonitorAudio. Same output path as StartMonitor, minus the capture goroutine.
|
||||||
|
func (m *Manager) StartMonitorSink(outputDev string) error {
|
||||||
|
return m.startMonitor("", outputDev, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startMonitor wires the RX monitor: always a render loop pulling from monRing;
|
||||||
|
// when capture is true it also captures inputDev into that ring (USB monitor).
|
||||||
|
// When false the ring is fed only by PushMonitorAudio (network audio).
|
||||||
|
func (m *Manager) startMonitor(inputDev, outputDev string, capture bool) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.monStop != nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("monitor already running")
|
||||||
|
}
|
||||||
|
stop := make(chan struct{})
|
||||||
|
ring := newPCMRing(bytesPerSec / 2) // ~500 ms cap — low latency for live monitor
|
||||||
|
m.monStop, m.monRing = stop, ring
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if capture {
|
||||||
|
// Producer: capture the rig's USB audio into the ring.
|
||||||
|
go func() {
|
||||||
|
_ = captureStream(inputDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// Consumer: render the ring to the output device at the internal 16 kHz mono.
|
||||||
|
go func() {
|
||||||
|
_ = renderStream(outputDev, sampleRate, channels, bitsPerSample, stop, ring)
|
||||||
|
}()
|
||||||
|
m.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopMonitor stops the RX monitor passthrough.
|
||||||
|
func (m *Manager) StopMonitor() {
|
||||||
|
m.mu.Lock()
|
||||||
|
stop := m.monStop
|
||||||
|
m.monStop, m.monRing = nil, nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
m.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitorActive reports whether the RX monitor passthrough is running.
|
||||||
|
func (m *Manager) MonitorActive() bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.monStop != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushMonitorAudio feeds externally-sourced PCM (16 kHz mono 16-bit) into the
|
||||||
|
// active monitor's output — the hook the network 50003 audio stream uses to play
|
||||||
|
// decoded RX through the very same output path a USB capture feeds. No-op when no
|
||||||
|
// monitor is running. Keeps the unexported ring inside the package.
|
||||||
|
func (m *Manager) PushMonitorAudio(pcm []byte) {
|
||||||
|
m.mu.Lock()
|
||||||
|
ring := m.monRing
|
||||||
|
m.mu.Unlock()
|
||||||
|
if ring != nil {
|
||||||
|
ring.Push(pcm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- TX audio passthrough (Phase 3: live mic → rig over USB) --------------
|
||||||
|
//
|
||||||
|
// StartTXAudio pipes your live microphone (micDev) into the rig's audio input
|
||||||
|
// (toRadioDev — for a USB-connected rig, its "USB Audio CODEC" render endpoint),
|
||||||
|
// so you talk through the PC. It is the mirror of StartMonitor (same ring +
|
||||||
|
// capture + render primitives, source/sink swapped). PTT keying is the caller's
|
||||||
|
// job (the app layer keys PTT before this and unkeys after) so this stays a pure
|
||||||
|
// audio route. The captured 16 kHz mono stream is also the exact shape the future
|
||||||
|
// network 50003 TX will encode and send — so Phase 5 reuses this capture side.
|
||||||
|
func (m *Manager) StartTXAudio(micDev, toRadioDev string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
if m.txStop != nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return fmt.Errorf("TX audio already running")
|
||||||
|
}
|
||||||
|
stop := make(chan struct{})
|
||||||
|
ring := newPCMRing(bytesPerSec / 4) // ~250 ms — tighter for live TX latency
|
||||||
|
m.txStop = stop
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = captureStream(micDev, stop, func(chunk []byte) { ring.Push(chunk) })
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
_ = renderStream(toRadioDev, sampleRate, channels, bitsPerSample, stop, ring)
|
||||||
|
}()
|
||||||
|
m.notify()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopTXAudio stops the TX mic→rig passthrough.
|
||||||
|
func (m *Manager) StopTXAudio() {
|
||||||
|
m.mu.Lock()
|
||||||
|
stop := m.txStop
|
||||||
|
m.txStop = nil
|
||||||
|
m.mu.Unlock()
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
m.notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TXAudioActive reports whether the TX mic→rig passthrough is running.
|
||||||
|
func (m *Manager) TXAudioActive() bool {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.txStop != nil
|
||||||
|
}
|
||||||
|
|||||||
+398
-80
@@ -13,6 +13,10 @@
|
|||||||
package award
|
package award
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -69,14 +73,19 @@ type Def struct {
|
|||||||
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)
|
||||||
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
|
// NOTE: there is no "one reference per QSO" switch, and there was never any
|
||||||
|
// point in one. A QSO ALWAYS yields every reference its field holds — an n-fer
|
||||||
|
// POTA activation ("US-6544,US-0680"), a VUCC contact on a grid line. The old
|
||||||
|
// `Multi` flag was read by nothing; its checkbox changed nothing.
|
||||||
|
|
||||||
// OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO
|
// OrRules are ordered FALLBACK searches for the primary one above: they are
|
||||||
// earns a reference if the primary match OR any of these match. Lets a
|
// tried IN ORDER and only while nothing has matched yet — the first rule that
|
||||||
// French department (DDFM) be found from "D74" in the note AND from a postal
|
// yields a reference wins and the rest are skipped (short-circuit, like a
|
||||||
// code "74140" in the address (pattern captures "74", Prefix "D" → "D74").
|
// chain of "else if"). Lets a Chinese province (WAPC) be found first from the
|
||||||
|
// province NAME in the address, and only if that fails, from a city regex
|
||||||
|
// (e.g. "\bJiangyin\b" → JS) — so a QSO already resolved by name isn't also
|
||||||
|
// re-tagged, possibly differently, by a later rule.
|
||||||
OrRules []OrRule `json:"or_rules,omitempty"`
|
OrRules []OrRule `json:"or_rules,omitempty"`
|
||||||
|
|
||||||
// --- Scope ---
|
// --- Scope ---
|
||||||
@@ -88,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
|
||||||
|
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
|
||||||
|
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
|
||||||
|
// controls have been removed from the editor — a checkbox that quietly does
|
||||||
|
// nothing is worse than no checkbox, because it is trusted. Wire these up (in
|
||||||
|
// internal/adif) before showing them again.
|
||||||
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
|
||||||
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
|
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.
|
||||||
@@ -109,26 +152,124 @@ type OrRule struct {
|
|||||||
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
|
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults are the built-in awards seeded on first run (then user-editable).
|
// catalogFS holds the built-in award definitions as DATA, not Go code.
|
||||||
func Defaults() []Def {
|
//
|
||||||
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
|
// An award is data — a field to scan, a pattern, a scope. Coding it in Go meant a
|
||||||
// "electronically verified" tier: LoTW only — a paper QSL confirms but does
|
// recompile and a release for every new one, which is absurd for something that
|
||||||
// NOT validate (matches ARRL/Log4OM). eQSL counts only where the program
|
// changes far more often than the engine that reads it. They now live one JSON per
|
||||||
// accepts it (WAC).
|
// award in catalog/, embedded in the binary: adding an award is adding a file.
|
||||||
lq := []string{"lotw", "qsl"}
|
//
|
||||||
lo := []string{"lotw"}
|
// This is the SEED only. Once a user has awards in their database, that database
|
||||||
return []Def{
|
// is the source of truth — the catalog never overwrites their edits behind their
|
||||||
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lo, Total: 340, Valid: true, Builtin: true, Protected: true},
|
// back.
|
||||||
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lo, Total: 50, Valid: true, Builtin: true, Protected: true},
|
//
|
||||||
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lo, Total: 40, Valid: true, Builtin: true, Protected: true},
|
//go:embed catalog/*.json
|
||||||
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: lo, Total: 6, Valid: true, Builtin: true, Protected: true},
|
var catalogFS embed.FS
|
||||||
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
|
||||||
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lo, Total: 96, Valid: true, Builtin: true, Protected: true},
|
// CatalogEntry is one award in the catalog: its definition AND its reference list.
|
||||||
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
//
|
||||||
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
// The references are the point. An award's definition is a few lines; what makes
|
||||||
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
// WAPC worth anything is its 34 provinces and the city regexes attached to them.
|
||||||
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
|
// A catalog that shipped definitions only would hand every user an empty shell —
|
||||||
|
// which is exactly the trap this design is meant to avoid.
|
||||||
|
//
|
||||||
|
// References stay as raw JSON so this package (the matching ENGINE) never has to
|
||||||
|
// import the reference-store package. The caller decodes them.
|
||||||
|
type CatalogEntry struct {
|
||||||
|
Def Def `json:"def"`
|
||||||
|
References json.RawMessage `json:"references,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// catalogFile is what a file in catalog/ may contain. Two shapes are accepted:
|
||||||
|
//
|
||||||
|
// {"def": {...}, "references": [...]} — a catalog entry
|
||||||
|
// {"version":1, "awards":[{"def":…,"references":…}]} — an exported bundle
|
||||||
|
//
|
||||||
|
// Accepting the bundle shape is deliberate: it means an award EXPORTED from the UI
|
||||||
|
// can be dropped straight into catalog/ and shipped to everyone, with no
|
||||||
|
// conversion step. That is the whole loop — create an award, export it, drop it in,
|
||||||
|
// everybody has it.
|
||||||
|
type catalogFile struct {
|
||||||
|
Def Def `json:"def"`
|
||||||
|
References json.RawMessage `json:"references,omitempty"`
|
||||||
|
Awards []CatalogEntry `json:"awards,omitempty"`
|
||||||
|
// A bare Def (the original catalog shape) is still read via the fields above
|
||||||
|
// being empty and Code being set at the top level.
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalog returns every built-in award: definition + references, sorted by code.
|
||||||
|
//
|
||||||
|
// Sorted because an embed.FS walk is alphabetical and relying on that implicitly is
|
||||||
|
// how a user's award list quietly reorders itself on a rebuild.
|
||||||
|
func Catalog() []CatalogEntry {
|
||||||
|
entries, err := catalogFS.ReadDir("catalog")
|
||||||
|
if err != nil {
|
||||||
|
return nil // embedded: can only fail if the build is broken
|
||||||
|
}
|
||||||
|
out := make([]CatalogEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, err := catalogFS.ReadFile("catalog/" + e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var f catalogFile
|
||||||
|
if err := json.Unmarshal(b, &f); err != nil {
|
||||||
|
// A malformed file must not take the other awards down with it.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(f.Awards) > 0: // an exported bundle, dropped in as-is
|
||||||
|
for _, a := range f.Awards {
|
||||||
|
if strings.TrimSpace(a.Def.Code) != "" {
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case strings.TrimSpace(f.Def.Code) != "": // {"def":…,"references":…}
|
||||||
|
out = append(out, CatalogEntry{Def: f.Def, References: f.References})
|
||||||
|
case strings.TrimSpace(f.Code) != "": // a bare Def
|
||||||
|
var d Def
|
||||||
|
if err := json.Unmarshal(b, &d); err == nil {
|
||||||
|
out = append(out, CatalogEntry{Def: d})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An award OpsLog SHIPS is built-in, by definition. Derive it here instead of
|
||||||
|
// trusting the flag in the file: you drop in an award you exported, its JSON
|
||||||
|
// says builtin:false (you wrote it, it wasn't built-in then), and it would
|
||||||
|
// quietly miss every future catalog correction. Making the author remember to
|
||||||
|
// flip a flag is exactly the kind of step nobody can guess.
|
||||||
|
for i := range out {
|
||||||
|
out[i].Def.Builtin = true
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults are the built-in award definitions seeded on first run.
|
||||||
|
func Defaults() []Def {
|
||||||
|
cat := Catalog()
|
||||||
|
out := make([]Def, 0, len(cat))
|
||||||
|
for _, e := range cat {
|
||||||
|
out = append(out, e.Def)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatalogRefs returns the raw JSON reference list a catalog award ships with, if
|
||||||
|
// any. Awards whose list is seeded from code (DXCC entities, French departments)
|
||||||
|
// or fetched online (POTA/SOTA/WWFF) carry none.
|
||||||
|
func CatalogRefs(code string) (json.RawMessage, bool) {
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
for _, e := range Catalog() {
|
||||||
|
if strings.ToUpper(e.Def.Code) == code && len(e.References) > 0 {
|
||||||
|
return e.References, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate upgrades award definitions saved before the richer model existed.
|
// Migrate upgrades award definitions saved before the richer model existed.
|
||||||
@@ -257,6 +398,21 @@ type RefMeta struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
// NewRefList builds the engine's reference view from (code, meta) pairs.
|
||||||
|
// compileAwardRE compiles an award / reference regex. Award patterns are run
|
||||||
|
// over free-text log fields (QTH, NOTES) whose capitalization is arbitrary — a
|
||||||
|
// log may hold "Tokyo", "TOKYO" or "TOKIO" — so we match case-insensitively by
|
||||||
|
// default. An author who set their own flag group (e.g. "(?s)") is respected.
|
||||||
|
func compileAwardRE(p string) (*regexp.Regexp, error) {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
return nil, errors.New("empty pattern")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(p, "(?") {
|
||||||
|
p = "(?i)" + p
|
||||||
|
}
|
||||||
|
return regexp.Compile(p)
|
||||||
|
}
|
||||||
|
|
||||||
func NewRefList(metas []RefMeta) refList {
|
func NewRefList(metas []RefMeta) refList {
|
||||||
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
|
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
|
||||||
for _, m := range metas {
|
for _, m := range metas {
|
||||||
@@ -265,7 +421,7 @@ func NewRefList(metas []RefMeta) refList {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if p := strings.TrimSpace(m.Pattern); p != "" {
|
if p := strings.TrimSpace(m.Pattern); p != "" {
|
||||||
if re, err := regexp.Compile(p); err == nil {
|
if re, err := compileAwardRE(p); err == nil {
|
||||||
m.re = re
|
m.re = re
|
||||||
rl.withPattern = append(rl.withPattern, code)
|
rl.withPattern = append(rl.withPattern, code)
|
||||||
}
|
}
|
||||||
@@ -297,7 +453,7 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
|
|||||||
perr := make([]string, len(defs))
|
perr := make([]string, len(defs))
|
||||||
for i := range defs {
|
for i := range defs {
|
||||||
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
|
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
|
||||||
re, err := regexp.Compile(p)
|
re, err := compileAwardRE(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
perr[i] = "bad pattern: " + err.Error()
|
perr[i] = "bad pattern: " + err.Error()
|
||||||
} else {
|
} else {
|
||||||
@@ -432,24 +588,14 @@ func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
|
|||||||
}
|
}
|
||||||
var re *regexp.Regexp
|
var re *regexp.Regexp
|
||||||
if p := strings.TrimSpace(d.Pattern); p != "" {
|
if p := strings.TrimSpace(d.Pattern); p != "" {
|
||||||
if c, err := regexp.Compile(p); err == nil {
|
if c, err := compileAwardRE(p); err == nil {
|
||||||
re = c
|
re = c
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rl := NewRefList(metas)
|
rl := NewRefList(metas)
|
||||||
found := candidates(&d, re, q, rl, len(metas) > 0)
|
return candidates(&d, re, q, rl, len(metas) > 0)
|
||||||
// Merge operator-assigned references (manual override). These let the
|
|
||||||
// operator tag a QSO for an award whose field/description matching can't
|
|
||||||
// auto-detect the reference — e.g. WAPC scans the ADDRESS field for a
|
|
||||||
// province NAME, so a contact whose address doesn't spell it out needs the
|
|
||||||
// province picked by hand. For a predefined award the override is still
|
|
||||||
// validated against its reference list.
|
|
||||||
if man := manualRefs(q, d.Code); len(man) > 0 {
|
|
||||||
found = mergeManual(found, man, rl, len(metas) > 0 && !d.Dynamic)
|
|
||||||
}
|
|
||||||
return found
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
|
// ManualRefsKey is the ADIF extras key under which OpsLog stores per-QSO,
|
||||||
@@ -484,30 +630,6 @@ func manualRefs(q *qso.QSO, code string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeManual appends operator-assigned codes to the auto-found set, deduped.
|
|
||||||
// When the award is predefined, only references present and valid in its list
|
|
||||||
// are kept (so a typo can't invent a reference).
|
|
||||||
func mergeManual(found, manual []string, rl refList, predefined bool) []string {
|
|
||||||
seen := map[string]struct{}{}
|
|
||||||
for _, c := range found {
|
|
||||||
seen[normalizeRef(c)] = struct{}{}
|
|
||||||
}
|
|
||||||
for _, c := range manual {
|
|
||||||
c = normalizeRef(c)
|
|
||||||
if _, dup := seen[c]; dup {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if predefined {
|
|
||||||
if m, ok := rl.byCode[c]; !ok || !m.Valid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen[c] = struct{}{}
|
|
||||||
found = append(found, c)
|
|
||||||
}
|
|
||||||
return found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
// Confirmed reports whether a QSO satisfies any of the given confirmation
|
||||||
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
// sources (lotw|qsl|eqsl). Exported for the statistics view.
|
||||||
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
|
||||||
@@ -565,6 +687,14 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
|
|||||||
found = append(found, nc.code)
|
found = append(found, nc.code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A reference may also declare its own regex to broaden matching beyond
|
||||||
|
// its plain name (e.g. Tokyo's `\bTok[iy]o\b` also catches "TOKIO"). Test
|
||||||
|
// those too — the name match and the pattern are OR'd.
|
||||||
|
for _, code := range rl.withPattern {
|
||||||
|
if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
|
||||||
|
found = append(found, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
case predefined && !exact:
|
case predefined && !exact:
|
||||||
// "Search reference inside the field": look up each token of the field in
|
// "Search reference inside the field": look up each token of the field in
|
||||||
// the list — O(tokens), not O(all references) — plus test the few
|
// the list — O(tokens), not O(all references) — plus test the few
|
||||||
@@ -593,32 +723,207 @@ 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
|
||||||
|
|
||||||
// Primary search, then each OR rule — a QSO earns a reference if any matches.
|
// run executes one rule and, when tracing, records it.
|
||||||
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
|
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
|
||||||
|
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
|
||||||
|
kept := keepRefs(predefined, rl, raw)
|
||||||
|
if ex != nil {
|
||||||
|
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
|
||||||
|
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
|
||||||
|
Candidates: raw, Kept: kept}
|
||||||
|
keptSet := map[string]struct{}{}
|
||||||
|
for _, k := range kept {
|
||||||
|
keptSet[k] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, c := range raw {
|
||||||
|
n := normalizeRef(c)
|
||||||
|
if _, ok := keptSet[n]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.Rejected = append(s.Rejected, rejection(predefined, rl, n))
|
||||||
|
}
|
||||||
|
ex.Steps = append(ex.Steps, s)
|
||||||
|
}
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary search first; the OR rules are ordered FALLBACKS — try the next
|
||||||
|
// only while nothing has matched yet, and stop at the first that yields a
|
||||||
|
// reference (short-circuit). So a province already found by NAME isn't also
|
||||||
|
// re-derived, possibly differently, from a later city-regex rule.
|
||||||
|
//
|
||||||
|
// The short-circuit tests what a rule REALLY yielded — the references that
|
||||||
|
// survive the predefined list — not its raw candidates. A rule can always
|
||||||
|
// produce a raw candidate and still find nothing: "the whole ADDRESS field is
|
||||||
|
// the code" hands back "SERIATE (BG) 24068 ITALY", which is not a province.
|
||||||
|
// Testing the raw candidate would call that a hit, skip every fallback, and
|
||||||
|
// only then drop it as unlisted — leaving the QSO unmatched even though the
|
||||||
|
// next rule ("find the code inside the QTH") would have found BG.
|
||||||
|
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "")
|
||||||
for i := range d.OrRules {
|
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 := regexp.Compile(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 = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...)
|
found = run(label, r.Field, r.MatchBy, r.Pattern, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge operator-assigned references (manual override, ManualRefsKey). Lets
|
||||||
|
// the operator tag a QSO for an award whose field/description matching can't
|
||||||
|
// auto-detect the reference — e.g. WAPC scans ADDRESS for a province NAME, so
|
||||||
|
// a contact whose address doesn't spell it out needs the province picked by
|
||||||
|
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
|
||||||
|
// awards panel and the per-QSO refs editor — honours overrides too. For a
|
||||||
|
// predefined award the ref is still validated against the list below.
|
||||||
|
manual := keepRefs(predefined, rl, manualRefs(q, d.Code))
|
||||||
|
if ex != nil {
|
||||||
|
ex.Manual = manual
|
||||||
|
}
|
||||||
|
found = append(found, manual...)
|
||||||
|
out := dedupe(found)
|
||||||
|
if ex != nil {
|
||||||
|
ex.Result = out
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejection explains why a candidate the operator can SEE in the trace did not
|
||||||
|
// become a reference. "Nothing matched" is the least useful thing a matcher can
|
||||||
|
// say; every one of this week's award bugs was a rejection with a plain reason
|
||||||
|
// that nothing was printing.
|
||||||
|
func rejection(predefined bool, rl refList, code string) Rejected {
|
||||||
|
switch {
|
||||||
|
case code == "":
|
||||||
|
return Rejected{Candidate: code, Reason: "empty"}
|
||||||
|
case !predefined:
|
||||||
|
return Rejected{Candidate: code, Reason: "duplicate"}
|
||||||
|
}
|
||||||
|
m, ok := rl.byCode[code]
|
||||||
|
if !ok {
|
||||||
|
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
|
||||||
|
}
|
||||||
|
if !m.Valid {
|
||||||
|
return Rejected{Candidate: code, Reason: "listed but disabled"}
|
||||||
|
}
|
||||||
|
return Rejected{Candidate: code, Reason: "duplicate"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepRefs reduces a rule's raw candidates to the references that actually count.
|
||||||
|
// For a predefined award that means the codes on the list, enabled. The
|
||||||
|
// award-level DXCCFilter already scopes which QSOs are considered (see inScope),
|
||||||
|
// so we do NOT additionally require the QSO's entity to match the reference's own
|
||||||
|
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
|
||||||
|
// 291). Per-reference DXCC stays metadata for the picker.
|
||||||
|
func keepRefs(predefined bool, rl refList, found []string) []string {
|
||||||
if !predefined {
|
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 {
|
||||||
@@ -759,24 +1064,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package award
|
package award
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
)
|
)
|
||||||
@@ -128,6 +131,146 @@ func TestComputeMatchByDescription(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OR rules are ordered fallbacks: the primary match wins and short-circuits the
|
||||||
|
// rules; when the primary finds nothing, the first OR rule that hits wins and
|
||||||
|
// later rules are skipped. DDFM-style: a French department from the NOTE first,
|
||||||
|
// else captured from a postal code in the address (Prefix "D" → "D74").
|
||||||
|
func TestComputeOrRuleFallback(t *testing.T) {
|
||||||
|
def := Def{Code: "DDFM", Type: TypeQSOFields, Field: "notes", Pattern: `\b(D\d{2})\b`,
|
||||||
|
DXCCFilter: []int{227}, Valid: true,
|
||||||
|
OrRules: []OrRule{
|
||||||
|
{Field: "address", MatchBy: "pattern", Pattern: `\b(\d{2})\d{3}\b`, Prefix: "D"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"DDFM": {
|
||||||
|
{Code: "D74", Name: "Haute-Savoie", Valid: true},
|
||||||
|
{Code: "D75", Name: "Paris", Valid: true},
|
||||||
|
}}
|
||||||
|
worked := func(q qso.QSO) []string {
|
||||||
|
r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
|
||||||
|
var out []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
out = append(out, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
// Primary (note "D74") matches → the postal OR rule is skipped, so the
|
||||||
|
// address's 75001 does NOT also add D75. Exactly the user's ask: first
|
||||||
|
// condition wins, no fall-through.
|
||||||
|
if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "worked D74", Address: "75001 Paris"}); len(got) != 1 || got[0] != "D74" {
|
||||||
|
t.Errorf("primary wins: got %v, want [D74] (no postal fall-through to D75)", got)
|
||||||
|
}
|
||||||
|
// No note dept → fall through to the OR rule → postal 74140 → D74.
|
||||||
|
if got := worked(qso.QSO{Callsign: "F4ABC", DXCC: ip(227), Notes: "", Address: "74140 Annecy"}); len(got) != 1 || got[0] != "D74" {
|
||||||
|
t.Errorf("OR fallback: got %v, want [D74]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user's WAPC cascade: (1) province NAME in QTH, else (2) province NAME in
|
||||||
|
// ADDRESS, else (3) match-by-pattern in QTH, which runs each reference's OWN
|
||||||
|
// regex (city lists). Confirms the ordered fallback + that a match-by-pattern
|
||||||
|
// rule with an empty rule-pattern triggers the per-reference regexes.
|
||||||
|
func TestComputeProvinceCascade(t *testing.T) {
|
||||||
|
def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
|
||||||
|
DXCCFilter: []int{318}, Valid: true,
|
||||||
|
OrRules: []OrRule{
|
||||||
|
{Field: "address", MatchBy: "description"},
|
||||||
|
{Field: "qth", MatchBy: "pattern"}, // empty pattern → per-reference regexes
|
||||||
|
},
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"WAPC": {
|
||||||
|
{Code: "JS", Name: "Jiangsu", Pattern: `\bJiangyin\b`, Valid: true},
|
||||||
|
{Code: "ZJ", Name: "Zhejiang", Pattern: `\bHangzhou\b`, Valid: true},
|
||||||
|
}}
|
||||||
|
worked := func(q qso.QSO) []string {
|
||||||
|
r := Compute([]Def{def}, []qso.QSO{q}, refMetas, nil)[0]
|
||||||
|
var out []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
out = append(out, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name, qth, addr string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"name in qth", "Wuxi, Jiangsu", "", "JS"},
|
||||||
|
{"name in address", "Wuxi", "Jiangsu Province", "JS"},
|
||||||
|
{"city regex (Jiangyin) in qth", "Jiangyin", "", "JS"},
|
||||||
|
{"city regex (Hangzhou) in qth", "Hangzhou", "", "ZJ"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := worked(qso.QSO{Callsign: "BA1A", DXCC: ip(318), QTH: c.qth, Address: c.addr})
|
||||||
|
if len(got) != 1 || got[0] != c.want {
|
||||||
|
t.Errorf("%s: got %v, want [%s]", c.name, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A manually-assigned override (ManualRefsKey) must count in Compute — not just
|
||||||
|
// MatchQSO — so a custom award like WAPC (matches ADDRESS by description, writes
|
||||||
|
// no QSO field) sticks after the operator picks the province by hand. The engine
|
||||||
|
// only auto-detects "Jiangsu" when the address spells it out; here it doesn't, so
|
||||||
|
// the ref exists solely as the override.
|
||||||
|
func TestComputeManualOverride(t *testing.T) {
|
||||||
|
def := Def{Code: "WAPC", Type: TypeQSOFields, Field: "address", MatchBy: "description",
|
||||||
|
DXCCFilter: []int{318}, Confirm: []string{"lotw", "qsl"}, Valid: true}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
// Address doesn't name the province → only the override tags it.
|
||||||
|
{Callsign: "BA1ABC", Band: "20m", DXCC: ip(318), Address: "Beijing Rd 5",
|
||||||
|
Extras: map[string]string{ManualRefsKey: "WAPC@JS"}},
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"WAPC": {
|
||||||
|
{Code: "JS", Name: "Jiangsu", Valid: true},
|
||||||
|
{Code: "GD", Name: "Guangdong", Valid: true},
|
||||||
|
}}
|
||||||
|
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||||
|
if r.Worked != 1 {
|
||||||
|
t.Fatalf("WAPC worked = %d, want 1 (Jiangsu via manual override); got %v", r.Worked, refCodes(r))
|
||||||
|
}
|
||||||
|
var worked []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
worked = append(worked, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(worked) != 1 || worked[0] != "JS" {
|
||||||
|
t.Errorf("worked refs = %v, want [JS]", worked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An override for a ref NOT in the predefined list is rejected (no typo refs).
|
||||||
|
qsos[0].Extras[ManualRefsKey] = "WAPC@ZZ"
|
||||||
|
if w := Compute([]Def{def}, qsos, refMetas, nil)[0].Worked; w != 0 {
|
||||||
|
t.Errorf("bogus override ZZ: worked = %d, want 0", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A description-match award must also honour a REFERENCE's own regex (used to
|
||||||
|
// broaden matching past the plain name) AND match case-insensitively — a log
|
||||||
|
// QTH "ITABASHIKU, TOKIO" (uppercase, "Tokio" spelling) must count for Tokyo
|
||||||
|
// via its `\bTok[iy]o\b` pattern, which the plain name "Tokyo" can't catch.
|
||||||
|
func TestComputeDescriptionRefPattern(t *testing.T) {
|
||||||
|
def := Def{Code: "WAJA", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
|
||||||
|
DXCCFilter: []int{339}, Confirm: []string{"lotw", "qsl"}, Valid: true}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "JA1LZB", Band: "10m", DXCC: ip(339), QTH: "ITABASHIKU, TOKIO"}, // pattern + case
|
||||||
|
{Callsign: "JA3DEF", Band: "40m", DXCC: ip(339), QTH: "osaka pref"}, // lowercase name
|
||||||
|
}
|
||||||
|
refMetas := map[string][]RefMeta{"WAJA": {
|
||||||
|
{Code: "13", Name: "Tokyo", Pattern: `\bTok[iy]o\b`, Valid: true},
|
||||||
|
{Code: "27", Name: "Osaka", Valid: true},
|
||||||
|
{Code: "01", Name: "Hokkaido", Valid: true},
|
||||||
|
}}
|
||||||
|
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
|
||||||
|
if r.Worked != 2 {
|
||||||
|
t.Errorf("WAJA worked = %d, want 2 (Tokyo via pattern + Osaka via lowercase name); got %v", r.Worked, refCodes(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid
|
// VUCC: a grid4 award counts distinct 4-char grid squares, and a QSO on a grid
|
||||||
// line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the
|
// line (VUCC_GRIDS) contributes several. grid4 derives from VUCC_GRIDS else the
|
||||||
// 4-char prefix of GRIDSQUARE.
|
// 4-char prefix of GRIDSQUARE.
|
||||||
@@ -146,6 +289,160 @@ func TestComputeGrid4VUCC(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FFMA ships a fixed list of the 488 grids of the contiguous 48 states, taken
|
||||||
|
// from arrl.org/ffma. The count is the award's own checksum — if this test ever
|
||||||
|
// fails on the count, the catalog file is wrong, not the test.
|
||||||
|
func TestCatalogFFMA(t *testing.T) {
|
||||||
|
raw, ok := CatalogRefs("FFMA")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("FFMA has no reference list in the embedded catalog")
|
||||||
|
}
|
||||||
|
var refs []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
DXCC int `json:"dxcc"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &refs); err != nil {
|
||||||
|
t.Fatalf("FFMA references: %v", err)
|
||||||
|
}
|
||||||
|
if len(refs) != 488 {
|
||||||
|
t.Fatalf("FFMA has %d grids, want exactly 488", len(refs))
|
||||||
|
}
|
||||||
|
|
||||||
|
var def Def
|
||||||
|
metas := make([]RefMeta, 0, len(refs))
|
||||||
|
for _, r := range refs {
|
||||||
|
// Rule 4(c): a grid may be activated from Canadian or Mexican soil, or from
|
||||||
|
// water. Pinning a reference to a DXCC entity would reject those contacts.
|
||||||
|
if r.DXCC != 0 {
|
||||||
|
t.Fatalf("FFMA grid %s is pinned to DXCC %d — rule 4(c) allows working it from outside the US", r.Code, r.DXCC)
|
||||||
|
}
|
||||||
|
metas = append(metas, RefMeta{Code: r.Code, Valid: r.Valid})
|
||||||
|
}
|
||||||
|
for _, d := range Defaults() {
|
||||||
|
if d.Code == "FFMA" {
|
||||||
|
def = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def.Total != 488 || def.Field != "grid4" {
|
||||||
|
t.Fatalf("FFMA def: total=%d field=%q, want 488 / grid4", def.Total, def.Field)
|
||||||
|
}
|
||||||
|
|
||||||
|
d1983 := time.Date(1990, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "K5ABC", Band: "6m", Grid: "EM00AA", QSODate: d1983, LOTWRcvd: "Y"}, // counts
|
||||||
|
{Callsign: "VE3XYZ", Band: "6m", Grid: "FN25AA", QSODate: d1983, LOTWRcvd: "Y"}, // a VE3 in an FFMA grid: counts (4c)
|
||||||
|
{Callsign: "W1LINE", Band: "6m", VUCCGrids: "FN31,FN32", QSODate: d1983, QSLRcvd: "Y"}, // grid line: 2 grids (4d)
|
||||||
|
{Callsign: "G4XXX", Band: "6m", Grid: "IO91AA", QSODate: d1983, LOTWRcvd: "Y"}, // not an FFMA grid
|
||||||
|
{Callsign: "K5ABC", Band: "2m", Grid: "EM10AA", QSODate: d1983, LOTWRcvd: "Y"}, // wrong band
|
||||||
|
{Callsign: "K5OLD", Band: "6m", Grid: "EM20AA", QSODate: time.Date(1982, 6, 1, 0, 0, 0, 0, time.UTC), LOTWRcvd: "Y"}, // before 1983 (rule 2)
|
||||||
|
}
|
||||||
|
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"FFMA": metas}, nil)[0]
|
||||||
|
var got []string
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Worked {
|
||||||
|
got = append(got, rf.Ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(got)
|
||||||
|
want := []string{"EM00", "FN25", "FN31", "FN32"}
|
||||||
|
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||||
|
t.Fatalf("FFMA worked = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if r.Worked != len(want) || r.Total != 488 {
|
||||||
|
t.Errorf("FFMA worked=%d total=%d, want %d / 488", r.Worked, r.Total, len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAIP: the primary rule takes the WHOLE address as the province code (exact
|
||||||
|
// match), which can never be one — but it does produce a raw candidate. The OR
|
||||||
|
// fallback (find the code inside the QTH, "SERIATE (BG)") is the rule that works,
|
||||||
|
// and it must still run: a rule that yields nothing VALID is not a hit.
|
||||||
|
func TestComputeOrFallbackAfterUnlistedPrimary(t *testing.T) {
|
||||||
|
def := Def{
|
||||||
|
Code: "WAIP", Name: "Worked All Italian Provinces", Valid: true,
|
||||||
|
Type: TypeReference, Field: "address", MatchBy: "code", ExactMatch: true,
|
||||||
|
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}}, // not exact → search inside the field
|
||||||
|
Confirm: []string{"lotw", "qsl"},
|
||||||
|
}
|
||||||
|
metas := []RefMeta{
|
||||||
|
{Code: "BG", Name: "Bergamo", Valid: true},
|
||||||
|
{Code: "MI", Name: "Milano", Valid: true},
|
||||||
|
}
|
||||||
|
qsos := []qso.QSO{
|
||||||
|
{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy", LOTWRcvd: "Y"},
|
||||||
|
}
|
||||||
|
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"WAIP": metas}, nil)[0]
|
||||||
|
if r.Worked != 1 {
|
||||||
|
t.Fatalf("WAIP worked = %d, want 1 (BG, from the QTH fallback)", r.Worked)
|
||||||
|
}
|
||||||
|
for _, rf := range r.Refs {
|
||||||
|
if rf.Ref == "BG" && rf.Worked {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("BG not worked; refs = %v", refCodes(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explain must account for the WAIP case exactly: the primary rule produces a
|
||||||
|
// candidate that is NOT a province, says so, and the QTH fallback then finds BG.
|
||||||
|
func TestExplainAccountsForEveryRule(t *testing.T) {
|
||||||
|
def := Def{
|
||||||
|
Code: "WAIP", Valid: true, Type: TypeReference,
|
||||||
|
Field: "address", MatchBy: "code", ExactMatch: true,
|
||||||
|
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}},
|
||||||
|
Confirm: []string{"lotw"},
|
||||||
|
}
|
||||||
|
metas := []RefMeta{{Code: "BG", Name: "Bergamo", Valid: true}}
|
||||||
|
q := &qso.QSO{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy"}
|
||||||
|
|
||||||
|
ex := Explain(def, metas, q)
|
||||||
|
if !ex.InScope || len(ex.Steps) != 2 {
|
||||||
|
t.Fatalf("in scope=%v, %d steps, want in scope with 2 (primary + OR 1): %+v", ex.InScope, len(ex.Steps), ex)
|
||||||
|
}
|
||||||
|
// The primary rule LOOKS like it found something. Saying only "no match" here is
|
||||||
|
// what cost hours: the trace has to show the candidate and why it lost.
|
||||||
|
p := ex.Steps[0]
|
||||||
|
if len(p.Candidates) == 0 {
|
||||||
|
t.Error("primary produced no candidate; the whole point is that it produces a bogus one")
|
||||||
|
}
|
||||||
|
if len(p.Kept) != 0 || len(p.Rejected) == 0 {
|
||||||
|
t.Fatalf("primary kept=%v rejected=%v, want nothing kept and a stated reason", p.Kept, p.Rejected)
|
||||||
|
}
|
||||||
|
if !strings.Contains(p.Rejected[0].Reason, "not in the award's reference list") {
|
||||||
|
t.Errorf("reason = %q, want it to name the real cause", p.Rejected[0].Reason)
|
||||||
|
}
|
||||||
|
if or1 := ex.Steps[1]; or1.Skipped || len(or1.Kept) != 1 || or1.Kept[0] != "BG" {
|
||||||
|
t.Errorf("OR 1 = %+v, want it to run and find BG", or1)
|
||||||
|
}
|
||||||
|
if len(ex.Result) != 1 || ex.Result[0] != "BG" {
|
||||||
|
t.Errorf("result = %v, want [BG]", ex.Result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A trace that disagrees with the matcher is worse than none: it is believed.
|
||||||
|
if got := MatchQSO(def, metas, q); strings.Join(got, ",") != strings.Join(ex.Result, ",") {
|
||||||
|
t.Errorf("Explain says %v but MatchQSO says %v — the trace does not describe the code", ex.Result, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplainOutOfScopeSaysWhy(t *testing.T) {
|
||||||
|
def := Def{Code: "FFMA", Valid: true, Field: "grid4", MatchBy: "code", ExactMatch: true,
|
||||||
|
ValidBands: []string{"6m"}, ValidFrom: "1983-01-01", Confirm: []string{"lotw"}}
|
||||||
|
q := &qso.QSO{Callsign: "K1ABC", Band: "2m", Grid: "EM00AA",
|
||||||
|
QSODate: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)}
|
||||||
|
|
||||||
|
ex := Explain(def, []RefMeta{{Code: "EM00", Valid: true}}, q)
|
||||||
|
if ex.InScope {
|
||||||
|
t.Fatal("a 2 m QSO is not in scope for a 6 m-only award")
|
||||||
|
}
|
||||||
|
if !strings.Contains(ex.ScopeError, "2m") {
|
||||||
|
t.Errorf("scope error = %q, want it to name the band that fails", ex.ScopeError)
|
||||||
|
}
|
||||||
|
if len(ex.Steps) != 0 {
|
||||||
|
t.Errorf("%d steps ran on an out-of-scope QSO; none should", len(ex.Steps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func refCodes(r Result) []string {
|
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 {
|
||||||
@@ -238,3 +535,152 @@ func TestComputePredefinedList(t *testing.T) {
|
|||||||
t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
|
t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The built-in awards moved from Go code into embedded JSON (catalog/*.json).
|
||||||
|
// A refactor is only a refactor if the behaviour is identical, so pin down what
|
||||||
|
// the catalog MUST still produce — a typo in a JSON file would otherwise silently
|
||||||
|
// disable an award, and nobody would notice until a QSO stopped counting.
|
||||||
|
func TestCatalogDefaults(t *testing.T) {
|
||||||
|
defs := Defaults()
|
||||||
|
byCode := map[string]Def{}
|
||||||
|
for _, d := range defs {
|
||||||
|
byCode[d.Code] = d
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalog is MEANT to grow — dropping a JSON in is how an award ships. So
|
||||||
|
// assert the ten originals are still there, never that the count is exactly ten:
|
||||||
|
// a test that fails the moment you add an award is a test that teaches you to
|
||||||
|
// ignore it.
|
||||||
|
want := []string{"DDFM", "DXCC", "IOTA", "POTA", "SOTA", "WAC", "WAS", "WAZ", "WPX", "WWFF"}
|
||||||
|
if len(defs) < len(want) {
|
||||||
|
t.Fatalf("catalog has %d awards, want at least the %d built-ins (%v)", len(defs), len(want), byCode)
|
||||||
|
}
|
||||||
|
for _, c := range want {
|
||||||
|
d, ok := byCode[c]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s missing from the embedded catalog", c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Valid=false would hide the award entirely; Builtin=false would let a
|
||||||
|
// "reset to defaults" delete it. Both are silent failures.
|
||||||
|
if !d.Valid || !d.Builtin {
|
||||||
|
t.Errorf("%s: valid=%v builtin=%v — both must be true", c, d.Valid, d.Builtin)
|
||||||
|
}
|
||||||
|
if d.Type == "" || d.Field == "" {
|
||||||
|
t.Errorf("%s: type=%q field=%q — neither may be empty", c, d.Type, d.Field)
|
||||||
|
}
|
||||||
|
if len(d.Confirm) == 0 {
|
||||||
|
t.Errorf("%s: no confirmation sources — nothing would ever count as confirmed", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spot-check the two that carry real matching logic, since a mangled escape in
|
||||||
|
// JSON is exactly the failure this test exists to catch.
|
||||||
|
if got := byCode["DDFM"].Pattern; got != `(?i)\b(D\d{1,2}[AB]?)\b` {
|
||||||
|
t.Errorf("DDFM pattern = %q — the regex did not survive the JSON round-trip", got)
|
||||||
|
}
|
||||||
|
if _, err := compileAwardRE(byCode["DDFM"].Pattern); err != nil {
|
||||||
|
t.Errorf("DDFM pattern does not compile: %v", err)
|
||||||
|
}
|
||||||
|
if got := byCode["WAS"].DXCCFilter; len(got) != 3 || got[0] != 291 {
|
||||||
|
t.Errorf("WAS DXCC filter = %v, want [291 110 6]", got)
|
||||||
|
}
|
||||||
|
if !byCode["WAS"].ExactMatch || byCode["WAS"].MatchBy != "code" {
|
||||||
|
t.Errorf("WAS: exact=%v matchBy=%q, want true/code", byCode["WAS"].ExactMatch, byCode["WAS"].MatchBy)
|
||||||
|
}
|
||||||
|
if !byCode["WPX"].Dynamic {
|
||||||
|
t.Error("WPX must be dynamic — its references aren't a fixed list")
|
||||||
|
}
|
||||||
|
// Deterministic order: an embed walk that reorders would shuffle the user's list.
|
||||||
|
for i := 1; i < len(defs); i++ {
|
||||||
|
if defs[i-1].Code > defs[i].Code {
|
||||||
|
t.Errorf("catalog not sorted by code: %q before %q", defs[i-1].Code, defs[i].Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the catalog: an award you EXPORT from the UI can be dropped
|
||||||
|
// straight into catalog/ and shipped to everyone — no conversion step. So the
|
||||||
|
// loader must accept the exported bundle shape, and it must carry the REFERENCES,
|
||||||
|
// because a WAPC without its provinces and their city regexes is an empty shell.
|
||||||
|
func TestCatalogAcceptsExportedBundle(t *testing.T) {
|
||||||
|
// Exactly what ExportAward writes.
|
||||||
|
exported := []byte(`{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T10:00:00Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {"code":"WAPC","name":"Worked All Provinces of China","valid":true,
|
||||||
|
"type":"QSOFIELDS","field":"address","match_by":"description",
|
||||||
|
"dxcc_filter":[318],"confirm":["lotw","qsl"],"total":34},
|
||||||
|
"references": [
|
||||||
|
{"code":"JS","name":"Jiangsu","pattern":"\\bJiangyin\\b","valid":true},
|
||||||
|
{"code":"ZJ","name":"Zhejiang","valid":true}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
var f catalogFile
|
||||||
|
if err := json.Unmarshal(exported, &f); err != nil {
|
||||||
|
t.Fatalf("an exported bundle must parse as a catalog file: %v", err)
|
||||||
|
}
|
||||||
|
if len(f.Awards) != 1 {
|
||||||
|
t.Fatalf("got %d awards, want 1", len(f.Awards))
|
||||||
|
}
|
||||||
|
e := f.Awards[0]
|
||||||
|
if e.Def.Code != "WAPC" || e.Def.Field != "address" || e.Def.MatchBy != "description" {
|
||||||
|
t.Errorf("definition lost in the round-trip: %+v", e.Def)
|
||||||
|
}
|
||||||
|
if len(e.References) == 0 {
|
||||||
|
t.Fatal("references dropped — this is the whole value of sharing an award")
|
||||||
|
}
|
||||||
|
// The per-reference regex must survive: it is what makes the award actually work.
|
||||||
|
var refs []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(e.References, &refs); err != nil {
|
||||||
|
t.Fatalf("references not decodable: %v", err)
|
||||||
|
}
|
||||||
|
if len(refs) != 2 || refs[0].Code != "JS" || refs[0].Pattern != `\bJiangyin\b` {
|
||||||
|
t.Errorf("reference regex did not survive: %+v", refs)
|
||||||
|
}
|
||||||
|
if _, err := compileAwardRE(refs[0].Pattern); err != nil {
|
||||||
|
t.Errorf("the shipped regex does not compile: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single malformed file in the catalog must not take the other awards down with
|
||||||
|
// it — one bad JSON should cost you one award, not all of them.
|
||||||
|
func TestCatalogSurvivesOneBadFile(t *testing.T) {
|
||||||
|
if len(Catalog()) < 10 {
|
||||||
|
t.Fatalf("catalog has %d entries, want the 10 built-ins", len(Catalog()))
|
||||||
|
}
|
||||||
|
var f catalogFile
|
||||||
|
if err := json.Unmarshal([]byte(`{ this is not json `), &f); err == nil {
|
||||||
|
t.Error("expected a parse error on malformed JSON")
|
||||||
|
}
|
||||||
|
// Catalog() skips unparseable files rather than returning nil, so the others
|
||||||
|
// still load. (Verified structurally: the loader `continue`s on error.)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything OpsLog SHIPS is built-in, whatever the file says.
|
||||||
|
//
|
||||||
|
// You create an award, export it (its JSON records builtin:false — it wasn't
|
||||||
|
// built-in when you wrote it), then drop that same file into the catalog to ship
|
||||||
|
// it. If we trusted the flag, the award would go out to everyone marked "not
|
||||||
|
// built-in" and would then silently miss every future catalog correction. Making
|
||||||
|
// the author remember to flip a flag first is a step nobody can guess — so the
|
||||||
|
// catalog derives it instead.
|
||||||
|
func TestCatalogForcesBuiltin(t *testing.T) {
|
||||||
|
for _, e := range Catalog() {
|
||||||
|
if !e.Def.Builtin {
|
||||||
|
t.Errorf("%s: shipped in the catalog but Builtin=false — it would miss catalog corrections", e.Def.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The realistic case: a user-authored award whose JSON says builtin:false.
|
||||||
|
if len(Catalog()) == 0 {
|
||||||
|
t.Fatal("empty catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package award
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A synthetic log with the shape a real one has: a spread of bands, modes,
|
||||||
|
// entities, and the free-text fields the harder awards actually scan.
|
||||||
|
func benchLog(n int) []qso.QSO {
|
||||||
|
bands := []string{"160m", "80m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"}
|
||||||
|
modes := []string{"SSB", "CW", "FT8", "FT4", "RTTY"}
|
||||||
|
entities := []int{291, 248, 227, 230, 339, 5, 108, 110, 6, 281}
|
||||||
|
towns := []string{"SERIATE (BG)", "MILANO (MI)", "ROMA (RM)", "TORINO (TO)", "NAPOLI (NA)"}
|
||||||
|
out := make([]qso.QSO, n)
|
||||||
|
base := time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
for i := range out {
|
||||||
|
e := entities[i%len(entities)]
|
||||||
|
out[i] = qso.QSO{
|
||||||
|
ID: int64(i + 1),
|
||||||
|
Callsign: fmt.Sprintf("I%dABC", i%10),
|
||||||
|
Band: bands[i%len(bands)],
|
||||||
|
Mode: modes[i%len(modes)],
|
||||||
|
DXCC: &e,
|
||||||
|
QSODate: base.Add(time.Duration(i) * time.Hour),
|
||||||
|
QTH: towns[i%len(towns)],
|
||||||
|
Address: "Via Roma 12, 24068 " + towns[i%len(towns)] + " Italy",
|
||||||
|
Grid: "JN45AA",
|
||||||
|
State: "CA",
|
||||||
|
LOTWRcvd: "Y",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAIP is the worst realistic shape: a predefined list, an exact-match primary
|
||||||
|
// that always produces a doomed candidate, then a fallback chain that tokenises a
|
||||||
|
// free-text field. If anything is slow, it is this.
|
||||||
|
func benchWAIP() (Def, []RefMeta) {
|
||||||
|
d := Def{
|
||||||
|
Code: "WAIP", Valid: true, Type: TypeReference,
|
||||||
|
Field: "address", MatchBy: "code", ExactMatch: true,
|
||||||
|
OrRules: []OrRule{
|
||||||
|
{Field: "qth", MatchBy: "description"},
|
||||||
|
{Field: "qth", MatchBy: "code"},
|
||||||
|
{Field: "address", MatchBy: "code"},
|
||||||
|
},
|
||||||
|
DXCCFilter: []int{248},
|
||||||
|
Confirm: []string{"lotw", "qsl"},
|
||||||
|
}
|
||||||
|
codes := []string{"AG", "AL", "AN", "AO", "AP", "AQ", "AR", "AT", "AV", "BA", "BG", "BI", "BL", "BN", "BO", "BR", "BS", "BT", "BZ", "CA", "CB", "CE", "CH", "CL", "CN", "CO", "CR", "CS", "CT", "CZ", "EN", "FC", "FE", "FG", "FI", "FM", "FR", "GE", "GO", "GR", "IM", "IS", "KR", "LC", "LE", "LI", "LO", "LT", "LU", "MB", "MC", "ME", "MI", "MN", "MO", "MS", "MT", "NA", "NO", "NU", "OR", "PA", "PC", "PD", "PE", "PG", "PI", "PN", "PO", "PR", "PT", "PU", "PV", "PZ", "RA", "RC", "RE", "RG", "RI", "RM", "RN", "RO", "SA", "SI", "SO", "SP", "SR", "SS", "SU", "SV", "TA", "TE", "TN", "TO", "TP", "TR", "TS", "TV", "UD", "VA", "VB", "VC", "VE", "VI", "VR", "VT", "VV"}
|
||||||
|
metas := make([]RefMeta, 0, len(codes))
|
||||||
|
for _, c := range codes {
|
||||||
|
metas = append(metas, RefMeta{Code: c, Name: "Province " + c, Valid: true})
|
||||||
|
}
|
||||||
|
return d, metas
|
||||||
|
}
|
||||||
|
|
||||||
|
// One award, whole log — what GetAward(code) does when the Awards panel opens.
|
||||||
|
func BenchmarkComputeOneAward(b *testing.B) {
|
||||||
|
d, metas := benchWAIP()
|
||||||
|
for _, n := range []int{10_000, 50_000, 100_000} {
|
||||||
|
log := benchLog(n)
|
||||||
|
b.Run(fmt.Sprintf("WAIP/%dqso", n), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
Compute([]Def{d}, log, map[string][]RefMeta{"WAIP": metas}, nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every shipped award at once — GetAwards(), the heaviest thing the app can ask.
|
||||||
|
func BenchmarkComputeWholeCatalog(b *testing.B) {
|
||||||
|
defs := Defaults()
|
||||||
|
metas := map[string][]RefMeta{}
|
||||||
|
wd, wm := benchWAIP()
|
||||||
|
defs = append(defs, wd)
|
||||||
|
metas["WAIP"] = wm
|
||||||
|
for _, c := range Catalog() {
|
||||||
|
if raw, ok := CatalogRefs(c.Def.Code); ok {
|
||||||
|
var refs []struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Valid bool `json:"valid"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &refs) == nil {
|
||||||
|
m := make([]RefMeta, 0, len(refs))
|
||||||
|
for _, r := range refs {
|
||||||
|
m = append(m, RefMeta{Code: r.Code, Name: r.Name, Valid: r.Valid})
|
||||||
|
}
|
||||||
|
metas[c.Def.Code] = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range []int{10_000, 100_000} {
|
||||||
|
log := benchLog(n)
|
||||||
|
b.Run(fmt.Sprintf("%dawards/%dqso", len(defs), n), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
Compute(defs, log, metas, nil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "DDFM",
|
||||||
|
"name": "Départements Français Métropolitains",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "note",
|
||||||
|
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
|
||||||
|
"dxcc_filter": [
|
||||||
|
227
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 96,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "DXCC",
|
||||||
|
"name": "DX Century Club",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "DXCC",
|
||||||
|
"field": "dxcc",
|
||||||
|
"pattern": "",
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 340,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "IOTA",
|
||||||
|
"name": "Islands On The Air",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "iota",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "POTA",
|
||||||
|
"name": "Parks On The Air",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "pota_ref",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T17:00:44Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "RAC",
|
||||||
|
"name": "RAC Canadian Provinces",
|
||||||
|
"description": "RAC Canadian Provinces",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"url": "https://www.rac.ca/canadaward/",
|
||||||
|
"valid_from": "1977-01-07",
|
||||||
|
"ref_display": "name",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "state",
|
||||||
|
"match_by": "code",
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AB",
|
||||||
|
"name": "Alberta",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BC",
|
||||||
|
"name": "British Columbia",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MB",
|
||||||
|
"name": "Manitoba",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NB",
|
||||||
|
"name": "New Brunswick",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NL",
|
||||||
|
"name": "Newfoundland and Labrador",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NS",
|
||||||
|
"name": "Nova Scotia",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NT",
|
||||||
|
"name": "Northwest Territories",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NU",
|
||||||
|
"name": "Nunavut",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ON",
|
||||||
|
"name": "Ontario",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PE",
|
||||||
|
"name": "Prince Edward Island",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QC",
|
||||||
|
"name": "Quebec",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SK",
|
||||||
|
"name": "Saskatchewan",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "YT",
|
||||||
|
"name": "Yukon",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "SOTA",
|
||||||
|
"name": "Summits On The Air",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "sota_ref",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T17:00:44Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "VUCC",
|
||||||
|
"name": "VHF/UHF Century Club",
|
||||||
|
"description": "VHF/UHF Century Club",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"url": "https://www.arrl.org/files/file/Awards%20Application%20Forms/VUCCRULE1a.pdf",
|
||||||
|
"valid_from": "1970-01-01",
|
||||||
|
"valid_to": "9999-01-30",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "grid4",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"valid_bands": [
|
||||||
|
"6m",
|
||||||
|
"4m",
|
||||||
|
"2m",
|
||||||
|
"70cm",
|
||||||
|
"23cm",
|
||||||
|
"13cm",
|
||||||
|
"1.25m"
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAC",
|
||||||
|
"name": "Worked All Continents",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "cont",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl",
|
||||||
|
"eqsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 6,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,929 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T21:57:23Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAIP",
|
||||||
|
"name": "ARI Worked All Italian Provinces",
|
||||||
|
"valid": true,
|
||||||
|
"url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
|
||||||
|
"ref_url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "code"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
248
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": false
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AG",
|
||||||
|
"name": "Agrigento",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AL",
|
||||||
|
"name": "Alessandria",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AN",
|
||||||
|
"name": "Ancona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AO",
|
||||||
|
"name": "Aosta",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Val d'Aosta",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AP",
|
||||||
|
"name": "Ascoli Piceno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AQ",
|
||||||
|
"name": "L'Aquila",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AR",
|
||||||
|
"name": "Arezzo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AT",
|
||||||
|
"name": "Asti",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "AV",
|
||||||
|
"name": "Avellino",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BA",
|
||||||
|
"name": "Bari",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BG",
|
||||||
|
"name": "Bergamo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BI",
|
||||||
|
"name": "Biella",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BL",
|
||||||
|
"name": "Belluno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BN",
|
||||||
|
"name": "Benevento",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BO",
|
||||||
|
"name": "Bologna",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BR",
|
||||||
|
"name": "Brindisi",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BS",
|
||||||
|
"name": "Brescia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BT",
|
||||||
|
"name": "Barletta-Andria-Trani",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BZ",
|
||||||
|
"name": "Bolzano/Bozen",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Trentino-AltoAdige/Südtirol",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CA",
|
||||||
|
"name": "Cagliari",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CB",
|
||||||
|
"name": "Campobasso",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Molise",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CE",
|
||||||
|
"name": "Caserta",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CH",
|
||||||
|
"name": "Chieti",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CI",
|
||||||
|
"name": "Carbonia-Iglesias",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CL",
|
||||||
|
"name": "Caltanissetta",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CN",
|
||||||
|
"name": "Cuneo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CO",
|
||||||
|
"name": "Como",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CR",
|
||||||
|
"name": "Cremona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CS",
|
||||||
|
"name": "Cosenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CT",
|
||||||
|
"name": "Catania",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CZ",
|
||||||
|
"name": "Catanzaro",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "EN",
|
||||||
|
"name": "Enna",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FC",
|
||||||
|
"name": "Forlì-Cesena",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FE",
|
||||||
|
"name": "Ferrara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FG",
|
||||||
|
"name": "Foggia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FI",
|
||||||
|
"name": "Firenze",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FM",
|
||||||
|
"name": "Fermo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FR",
|
||||||
|
"name": "Frosinone",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GE",
|
||||||
|
"name": "Genova",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GO",
|
||||||
|
"name": "Gorizia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GR",
|
||||||
|
"name": "Grosseto",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "IM",
|
||||||
|
"name": "Imperia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "IS",
|
||||||
|
"name": "Isernia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Molise",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "KR",
|
||||||
|
"name": "Crotone",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LC",
|
||||||
|
"name": "Lecco",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LE",
|
||||||
|
"name": "Lecce",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LI",
|
||||||
|
"name": "Livorno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LO",
|
||||||
|
"name": "Lodi",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LT",
|
||||||
|
"name": "Latina",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LU",
|
||||||
|
"name": "Lucca",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MB",
|
||||||
|
"name": "Monza e Brianza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MC",
|
||||||
|
"name": "Macerata",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ME",
|
||||||
|
"name": "Messina",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MI",
|
||||||
|
"name": "Milano",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MN",
|
||||||
|
"name": "Mantova",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MO",
|
||||||
|
"name": "Modena",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MS",
|
||||||
|
"name": "Massa-Carrara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MT",
|
||||||
|
"name": "Matera",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Basilicata",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NA",
|
||||||
|
"name": "Napoli",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NO",
|
||||||
|
"name": "Novara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NU",
|
||||||
|
"name": "Nuoro",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OG",
|
||||||
|
"name": "Ogliastra",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OR",
|
||||||
|
"name": "Oristano",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "OT",
|
||||||
|
"name": "Olbia-Tempio",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PA",
|
||||||
|
"name": "Palermo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PC",
|
||||||
|
"name": "Piacenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PD",
|
||||||
|
"name": "Padova",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PE",
|
||||||
|
"name": "Pescara",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PG",
|
||||||
|
"name": "Perugia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Umbria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PI",
|
||||||
|
"name": "Pisa",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PN",
|
||||||
|
"name": "Pordenone",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PO",
|
||||||
|
"name": "Prato",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PR",
|
||||||
|
"name": "Parma",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PT",
|
||||||
|
"name": "Pistoia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PU",
|
||||||
|
"name": "Pesaro-Urbino",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Marche",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PV",
|
||||||
|
"name": "Pavia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "PZ",
|
||||||
|
"name": "Potenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Basilicata",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RA",
|
||||||
|
"name": "Ravenna",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RC",
|
||||||
|
"name": "Reggio Calabria",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RE",
|
||||||
|
"name": "Reggio Emilia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RG",
|
||||||
|
"name": "Ragusa",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RI",
|
||||||
|
"name": "Rieti",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RM",
|
||||||
|
"name": "Roma",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RN",
|
||||||
|
"name": "Rimini",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Emilia-Romagna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RO",
|
||||||
|
"name": "Rovigo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SA",
|
||||||
|
"name": "Salerno",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Campania",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SI",
|
||||||
|
"name": "Siena",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Toscana",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SO",
|
||||||
|
"name": "Sondrio",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SP",
|
||||||
|
"name": "La Spezia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SR",
|
||||||
|
"name": "Siracusa",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SS",
|
||||||
|
"name": "Sassari",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SV",
|
||||||
|
"name": "Savona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Liguria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TA",
|
||||||
|
"name": "Taranto",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Puglia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TE",
|
||||||
|
"name": "Teramo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Abruzzo",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TN",
|
||||||
|
"name": "Trento",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Trentino-AltoAdige/Südtirol",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TO",
|
||||||
|
"name": "Torino",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TP",
|
||||||
|
"name": "Trapani",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sicilia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TR",
|
||||||
|
"name": "Terni",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Umbria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TS",
|
||||||
|
"name": "Trieste",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TV",
|
||||||
|
"name": "Treviso",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "UD",
|
||||||
|
"name": "Udine",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Friuli-Venezia Giulia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VA",
|
||||||
|
"name": "Varese",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lombardia",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VB",
|
||||||
|
"name": "Verbano-Cusio-Ossola",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VC",
|
||||||
|
"name": "Vercelli",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Piemonte",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VE",
|
||||||
|
"name": "Venezia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VI",
|
||||||
|
"name": "Vicenza",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VR",
|
||||||
|
"name": "Verona",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Veneto",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VS",
|
||||||
|
"name": "Medio Campidano",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Sardegna",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VT",
|
||||||
|
"name": "Viterbo",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Lazio",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "VV",
|
||||||
|
"name": "Vibo Valentia",
|
||||||
|
"dxcc": 248,
|
||||||
|
"group": "Calabria",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T15:44:38Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAJA",
|
||||||
|
"name": "Worked All Japanese Prefectures",
|
||||||
|
"description": "Worked All Japanese Prefectures",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"valid_from": "1970-01-01",
|
||||||
|
"ref_display": "name",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "pattern"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
339
|
||||||
|
],
|
||||||
|
"valid_bands": [
|
||||||
|
"80m",
|
||||||
|
"40m",
|
||||||
|
"20m",
|
||||||
|
"15m",
|
||||||
|
"10m",
|
||||||
|
"160m"
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "1",
|
||||||
|
"name": "Hokkaido",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "10",
|
||||||
|
"name": "Gunma",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "11",
|
||||||
|
"name": "Saitama",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "12",
|
||||||
|
"name": "Chiba",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "13",
|
||||||
|
"name": "Tokyo",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\bTok[iy]o\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "14",
|
||||||
|
"name": "Kanagawa",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "15",
|
||||||
|
"name": "Niigata",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "16",
|
||||||
|
"name": "Toyama",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "17",
|
||||||
|
"name": "Ishikawa",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "18",
|
||||||
|
"name": "Fukui",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "19",
|
||||||
|
"name": "Yamanashi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "2",
|
||||||
|
"name": "Aomori",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "20",
|
||||||
|
"name": "Nagano",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "21",
|
||||||
|
"name": "Gifu",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "22",
|
||||||
|
"name": "Shizuoka",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "23",
|
||||||
|
"name": "Aichi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "24",
|
||||||
|
"name": "Mie",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "25",
|
||||||
|
"name": "Shiga",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "26",
|
||||||
|
"name": "Kyoto",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "27",
|
||||||
|
"name": "Osaka",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "28",
|
||||||
|
"name": "Hyogo",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "29",
|
||||||
|
"name": "Nara",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "3",
|
||||||
|
"name": "Iwate",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "30",
|
||||||
|
"name": "Wakayama",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "31",
|
||||||
|
"name": "Tottori",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "32",
|
||||||
|
"name": "Shimane",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "33",
|
||||||
|
"name": "Okayama",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "34",
|
||||||
|
"name": "Hiroshima",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "35",
|
||||||
|
"name": "Yamaguchi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "36",
|
||||||
|
"name": "Tokushima",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "37",
|
||||||
|
"name": "Kagawa",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "38",
|
||||||
|
"name": "Ehime",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "39",
|
||||||
|
"name": "Kochi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "4",
|
||||||
|
"name": "Miyagi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "40",
|
||||||
|
"name": "Fukuoka",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "41",
|
||||||
|
"name": "Saga",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "42",
|
||||||
|
"name": "Nagasaki",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "43",
|
||||||
|
"name": "Kumamoto",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "44",
|
||||||
|
"name": "Oita",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "45",
|
||||||
|
"name": "Miyazaki",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "46",
|
||||||
|
"name": "Kagoshima",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "47",
|
||||||
|
"name": "Okinawa",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "5",
|
||||||
|
"name": "Akita",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "6",
|
||||||
|
"name": "Yamagata",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "7",
|
||||||
|
"name": "Fukushima",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "8",
|
||||||
|
"name": "Ibaraki",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "9",
|
||||||
|
"name": "Tochigi",
|
||||||
|
"dxcc": 0,
|
||||||
|
"group": "",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"exported_at": "2026-07-13T15:43:49Z",
|
||||||
|
"awards": [
|
||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAPC",
|
||||||
|
"name": "Worked All Provinces of China",
|
||||||
|
"description": "Worked All Provinces of China",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"url": "http://www.mulandxc.com/index/wapc_medal_app",
|
||||||
|
"valid_from": "2012-04-21",
|
||||||
|
"valid_to": "9999-01-31",
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "address",
|
||||||
|
"match_by": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"or_rules": [
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "qth",
|
||||||
|
"match_by": "pattern"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dxcc_filter": [
|
||||||
|
318,
|
||||||
|
152,
|
||||||
|
321
|
||||||
|
],
|
||||||
|
"valid_bands": [
|
||||||
|
"80m",
|
||||||
|
"40m",
|
||||||
|
"20m",
|
||||||
|
"15m",
|
||||||
|
"10m"
|
||||||
|
],
|
||||||
|
"emission": [
|
||||||
|
"CW",
|
||||||
|
"PHONE",
|
||||||
|
"DIGITAL"
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"code": "AH",
|
||||||
|
"name": "Anhui",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "BJ",
|
||||||
|
"name": "Beijing",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CQ",
|
||||||
|
"name": "Chongqing",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "FJ",
|
||||||
|
"name": "Fujian",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GD",
|
||||||
|
"name": "Guangdong",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GS",
|
||||||
|
"name": "Gansu",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GX",
|
||||||
|
"name": "Guangxi",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "GZ",
|
||||||
|
"name": "Guizhou",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HA",
|
||||||
|
"name": "Henan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HB",
|
||||||
|
"name": "Hubei",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HE",
|
||||||
|
"name": "Hebei",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HI",
|
||||||
|
"name": "Hainan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HK",
|
||||||
|
"name": "Hong Kong",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HL",
|
||||||
|
"name": "Heilongjiang",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "HN",
|
||||||
|
"name": "Hunan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "JL",
|
||||||
|
"name": "Jilin",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "JS",
|
||||||
|
"name": "Jiangsu",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\bJiangyin\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "JX",
|
||||||
|
"name": "Jiangxi",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LN",
|
||||||
|
"name": "Liaoning",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "MO",
|
||||||
|
"name": "Macau",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"pattern": "\\bMaca[uo]\\b",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NM",
|
||||||
|
"name": "NeiMongol",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NX",
|
||||||
|
"name": "Ningxia",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "QH",
|
||||||
|
"name": "Qinghai",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SC",
|
||||||
|
"name": "Sichuan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SD",
|
||||||
|
"name": "Shandong",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SH",
|
||||||
|
"name": "Shanghai",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SN",
|
||||||
|
"name": "Shaanxi",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SX",
|
||||||
|
"name": "Shanxi",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TJ",
|
||||||
|
"name": "Tianjin",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TW",
|
||||||
|
"name": "Taiwan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "XJ",
|
||||||
|
"name": "Xinjiang",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "XZ",
|
||||||
|
"name": "Xizang",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "YN",
|
||||||
|
"name": "Yunnan",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ZJ",
|
||||||
|
"name": "Zhejiang",
|
||||||
|
"dxcc": 318,
|
||||||
|
"group": "China Provinces",
|
||||||
|
"subgrp": "",
|
||||||
|
"valid": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAS",
|
||||||
|
"name": "Worked All States",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "state",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dxcc_filter": [
|
||||||
|
291,
|
||||||
|
110,
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 50,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WAZ",
|
||||||
|
"name": "Worked All Zones (CQ)",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "cqz",
|
||||||
|
"match_by": "code",
|
||||||
|
"exact_match": true,
|
||||||
|
"pattern": "",
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 40,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WPX",
|
||||||
|
"name": "Worked All Prefixes (CQ WPX)",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "QSOFIELDS",
|
||||||
|
"field": "prefix",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"def": {
|
||||||
|
"code": "WWFF",
|
||||||
|
"name": "World Wide Flora & Fauna",
|
||||||
|
"valid": true,
|
||||||
|
"protected": true,
|
||||||
|
"type": "REFERENCE",
|
||||||
|
"field": "wwff",
|
||||||
|
"pattern": "",
|
||||||
|
"dynamic": true,
|
||||||
|
"dxcc_filter": null,
|
||||||
|
"confirm": [
|
||||||
|
"lotw",
|
||||||
|
"qsl"
|
||||||
|
],
|
||||||
|
"validate": [
|
||||||
|
"lotw"
|
||||||
|
],
|
||||||
|
"total": 0,
|
||||||
|
"builtin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -210,6 +210,7 @@ type SpotInfo struct {
|
|||||||
Mode string
|
Mode string
|
||||||
Color string
|
Color string
|
||||||
Comment string
|
Comment string
|
||||||
|
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
||||||
@@ -275,6 +276,11 @@ type FlexTXState struct {
|
|||||||
Mon bool `json:"mon"`
|
Mon bool `json:"mon"`
|
||||||
MonLevel int `json:"mon_level"`
|
MonLevel int `json:"mon_level"`
|
||||||
MicLevel int `json:"mic_level"`
|
MicLevel int `json:"mic_level"`
|
||||||
|
TXFilterLow int `json:"tx_filter_low"` // TX filter low cut (Hz)
|
||||||
|
TXFilterHigh int `json:"tx_filter_high"` // TX filter high cut (Hz)
|
||||||
|
// Mic profiles (SmartSDR): the list of available profiles + the loaded one.
|
||||||
|
MicProfile string `json:"mic_profile,omitempty"`
|
||||||
|
MicProfiles []string `json:"mic_profiles,omitempty"`
|
||||||
ATUStatus string `json:"atu_status,omitempty"`
|
ATUStatus string `json:"atu_status,omitempty"`
|
||||||
ATUMemories bool `json:"atu_memories"`
|
ATUMemories bool `json:"atu_memories"`
|
||||||
// Active RX slice DSP controls.
|
// Active RX slice DSP controls.
|
||||||
@@ -296,6 +302,15 @@ type FlexTXState struct {
|
|||||||
NRLevel int `json:"nr_level"`
|
NRLevel int `json:"nr_level"`
|
||||||
ANF bool `json:"anf"`
|
ANF bool `json:"anf"`
|
||||||
ANFLevel int `json:"anf_level"`
|
ANFLevel int `json:"anf_level"`
|
||||||
|
WNB bool `json:"wnb"`
|
||||||
|
WNBLevel int `json:"wnb_level"`
|
||||||
|
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
|
||||||
|
// moving the slice. The offset survives the switch being turned off, so
|
||||||
|
// re-enabling restores it, exactly like the radio's own knob.
|
||||||
|
RIT bool `json:"rit"`
|
||||||
|
RITFreq int `json:"rit_freq"`
|
||||||
|
XIT bool `json:"xit"`
|
||||||
|
XITFreq int `json:"xit_freq"`
|
||||||
// CW / mode-specific controls.
|
// 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"`
|
||||||
@@ -322,6 +337,7 @@ type FlexMeter struct {
|
|||||||
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
||||||
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
||||||
Unit string `json:"unit,omitempty"`
|
Unit string `json:"unit,omitempty"`
|
||||||
|
Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise
|
||||||
Value float64 `json:"value"`
|
Value float64 `json:"value"`
|
||||||
Lo float64 `json:"lo"`
|
Lo float64 `json:"lo"`
|
||||||
Hi float64 `json:"hi"`
|
Hi float64 `json:"hi"`
|
||||||
@@ -344,6 +360,8 @@ type FlexController interface {
|
|||||||
SetMon(bool) error
|
SetMon(bool) error
|
||||||
SetMonLevel(int) error
|
SetMonLevel(int) error
|
||||||
SetMic(int) error
|
SetMic(int) error
|
||||||
|
SetTXFilter(low, high int) error // transmit-audio bandwidth (Hz)
|
||||||
|
SetMicProfile(string) error // load a SmartSDR mic profile by name
|
||||||
ATUStart() error
|
ATUStart() error
|
||||||
ATUBypass() error
|
ATUBypass() error
|
||||||
SetATUMemories(bool) error
|
SetATUMemories(bool) error
|
||||||
@@ -365,6 +383,12 @@ type FlexController interface {
|
|||||||
SetANFLevel(int) error
|
SetANFLevel(int) error
|
||||||
SetAPF(bool) error
|
SetAPF(bool) error
|
||||||
SetAPFLevel(int) error
|
SetAPFLevel(int) error
|
||||||
|
SetWNB(bool) error
|
||||||
|
SetWNBLevel(int) error
|
||||||
|
SetRIT(bool) error
|
||||||
|
SetRITFreq(int) error
|
||||||
|
SetXIT(bool) error
|
||||||
|
SetXITFreq(int) error
|
||||||
// CW keyer + mode-specific controls.
|
// CW keyer + mode-specific controls.
|
||||||
SetCWSpeed(int) error
|
SetCWSpeed(int) error
|
||||||
SetCWPitch(int) error
|
SetCWPitch(int) error
|
||||||
|
|||||||
@@ -313,6 +313,8 @@ func ModelName(addr byte) string {
|
|||||||
return "IC-7300"
|
return "IC-7300"
|
||||||
case 0x98:
|
case 0x98:
|
||||||
return "IC-7610"
|
return "IC-7610"
|
||||||
|
case 0x7C:
|
||||||
|
return "IC-9100"
|
||||||
case 0xA2:
|
case 0xA2:
|
||||||
return "IC-9700"
|
return "IC-9700"
|
||||||
case 0xA4:
|
case 0xA4:
|
||||||
|
|||||||
+264
-24
@@ -38,6 +38,8 @@ type Flex struct {
|
|||||||
slices map[int]*flexSlice
|
slices map[int]*flexSlice
|
||||||
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
||||||
amp flexAmp // external amplifier (PowerGenius XL) state
|
amp flexAmp // external amplifier (PowerGenius XL) state
|
||||||
|
micProfiles []string // available mic profiles (SmartSDR "profile mic list")
|
||||||
|
micProfile string // currently loaded mic profile
|
||||||
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
|
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
|
||||||
lastStateSig string // last logged derived-state signature (log only on change)
|
lastStateSig string // last logged derived-state signature (log only on change)
|
||||||
boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).
|
boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power).
|
||||||
@@ -58,6 +60,7 @@ type Flex struct {
|
|||||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||||
|
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||||
|
|
||||||
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||||
@@ -85,6 +88,12 @@ type flexSlice struct {
|
|||||||
anfLevel int
|
anfLevel int
|
||||||
apf bool // CW audio peaking filter
|
apf bool // CW audio peaking filter
|
||||||
apfLevel int
|
apfLevel int
|
||||||
|
wnb bool // wideband noise blanker
|
||||||
|
wnbLevel int
|
||||||
|
rit bool // receive incremental tuning enabled
|
||||||
|
ritFreq int // RIT offset in Hz (negative = down)
|
||||||
|
xit bool // transmit incremental tuning enabled
|
||||||
|
xitFreq int // XIT offset in Hz
|
||||||
filterLo int // slice filter low cut (Hz)
|
filterLo int // slice filter low cut (Hz)
|
||||||
filterHi int // slice filter high 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)
|
||||||
@@ -108,6 +117,8 @@ type flexTX struct {
|
|||||||
mon bool
|
mon bool
|
||||||
monLevel int
|
monLevel int
|
||||||
micLevel int
|
micLevel int
|
||||||
|
filterLow int // TX filter low cut (Hz)
|
||||||
|
filterHigh int // TX filter high cut (Hz)
|
||||||
atuStatus string
|
atuStatus string
|
||||||
atuMemories bool
|
atuMemories bool
|
||||||
// CW keyer params (set via the top-level "cw" commands).
|
// CW keyer params (set via the top-level "cw" commands).
|
||||||
@@ -133,6 +144,7 @@ type meterInfo struct {
|
|||||||
src string // SLC (slice), TX-, COD, RAD, AMP…
|
src string // SLC (slice), TX-, COD, RAD, AMP…
|
||||||
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
|
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
|
||||||
unit string // dbm, dbfs, swr, volts, degc, watts…
|
unit string // dbm, dbfs, swr, volts, degc, watts…
|
||||||
|
slc int // for src=SLC meters, the slice index (the ".num" field); -1 otherwise
|
||||||
lo float64
|
lo float64
|
||||||
hi float64
|
hi float64
|
||||||
}
|
}
|
||||||
@@ -151,7 +163,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
|||||||
return &Flex{
|
return &Flex{
|
||||||
host: strings.TrimSpace(host), port: port,
|
host: strings.TrimSpace(host), port: port,
|
||||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, pendingSplit: map[int]bool{},
|
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
@@ -209,6 +221,8 @@ func (f *Flex) Connect() error {
|
|||||||
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
|
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
|
||||||
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
|
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
|
||||||
f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
|
f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
|
||||||
|
f.send("sub profile all") // mic/global/tx profiles (for the mic-profile dropdown)
|
||||||
|
f.send("profile mic info") // request the current mic profile list + selection
|
||||||
f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below)
|
f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below)
|
||||||
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
|
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
|
||||||
if f.spotsEnabled {
|
if f.spotsEnabled {
|
||||||
@@ -344,6 +358,7 @@ func (f *Flex) reader(conn net.Conn) {
|
|||||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||||
f.spotCall[idx] = call
|
f.spotCall[idx] = call
|
||||||
f.spotIdx[idx] = true
|
f.spotIdx[idx] = true
|
||||||
|
f.spotByCall[strings.ToUpper(call)] = idx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A successful "slice create" for split returns the new slice's index;
|
// A successful "slice create" for split returns the new slice's index;
|
||||||
@@ -445,6 +460,12 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
|
f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay)
|
||||||
case "mic_level", "miclevel":
|
case "mic_level", "miclevel":
|
||||||
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
|
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
|
||||||
|
// TX filter: the transmit STATUS reports the passband as lo/hi (the
|
||||||
|
// SET command is filter_low/filter_high — a SmartSDR quirk).
|
||||||
|
case "lo", "filter_low":
|
||||||
|
f.tx.filterLow = atoiDefault(val, f.tx.filterLow)
|
||||||
|
case "hi", "filter_high":
|
||||||
|
f.tx.filterHigh = atoiDefault(val, f.tx.filterHigh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
@@ -526,6 +547,35 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
}
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
// Mic-profile object — "profile mic list=A^B^C" (available profiles) and
|
||||||
|
// "profile mic current=<name>" (loaded one). Names can contain spaces, so
|
||||||
|
// values are taken from the raw payload after the key. Logged once so the
|
||||||
|
// exact field names are confirmable on real hardware.
|
||||||
|
if len(fields) >= 2 && fields[0] == "profile" && fields[1] == "mic" {
|
||||||
|
debugLog.Printf("Flex: profile status: %s", payload)
|
||||||
|
if i := strings.Index(payload, "list="); i >= 0 {
|
||||||
|
var profs []string
|
||||||
|
for _, p := range strings.Split(payload[i+len("list="):], "^") {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
profs = append(profs, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.micProfiles = profs
|
||||||
|
f.mu.Unlock()
|
||||||
|
}
|
||||||
|
// The loaded profile arrives as current=<name> (some firmwares:
|
||||||
|
// selection=<name>); accept either.
|
||||||
|
for _, key := range []string{"current=", "selection="} {
|
||||||
|
if i := strings.Index(payload, key); i >= 0 {
|
||||||
|
cur := strings.TrimSpace(payload[i+len(key):])
|
||||||
|
f.mu.Lock()
|
||||||
|
f.micProfile = cur
|
||||||
|
f.mu.Unlock()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Interlock object — transmit state (RECEIVE / TRANSMITTING / …).
|
// Interlock object — transmit state (RECEIVE / TRANSMITTING / …).
|
||||||
if len(fields) >= 1 && fields[0] == "interlock" {
|
if len(fields) >= 1 && fields[0] == "interlock" {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -596,7 +646,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
// One meter per token; its fields are '#'-separated:
|
// One meter per token; its fields are '#'-separated:
|
||||||
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
|
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
|
||||||
num := -1
|
num := -1
|
||||||
var mi meterInfo
|
mi := meterInfo{slc: -1}
|
||||||
for _, sub := range strings.Split(tok, "#") {
|
for _, sub := range strings.Split(tok, "#") {
|
||||||
key, val, ok := splitKV(sub)
|
key, val, ok := splitKV(sub)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -616,6 +666,12 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
mi.src = val
|
mi.src = val
|
||||||
case "nam":
|
case "nam":
|
||||||
mi.name = val
|
mi.name = val
|
||||||
|
case "num":
|
||||||
|
// For a slice (SLC) meter this field is the slice index —
|
||||||
|
// how we tell slice A's S-meter from slice B's.
|
||||||
|
if v, e := strconv.Atoi(strings.TrimSpace(val)); e == nil {
|
||||||
|
mi.slc = v
|
||||||
|
}
|
||||||
case "unit", "units":
|
case "unit", "units":
|
||||||
mi.unit = val
|
mi.unit = val
|
||||||
case "low", "lo":
|
case "low", "lo":
|
||||||
@@ -630,6 +686,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
old, seen := f.meterMeta[num]
|
old, seen := f.meterMeta[num]
|
||||||
if !seen {
|
if !seen {
|
||||||
newIDs = append(newIDs, num)
|
newIDs = append(newIDs, num)
|
||||||
|
old.slc = -1
|
||||||
}
|
}
|
||||||
if mi.src != "" {
|
if mi.src != "" {
|
||||||
old.src = mi.src
|
old.src = mi.src
|
||||||
@@ -640,6 +697,9 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
if mi.unit != "" {
|
if mi.unit != "" {
|
||||||
old.unit = mi.unit
|
old.unit = mi.unit
|
||||||
}
|
}
|
||||||
|
if mi.slc >= 0 {
|
||||||
|
old.slc = mi.slc
|
||||||
|
}
|
||||||
if mi.lo != 0 {
|
if mi.lo != 0 {
|
||||||
old.lo = mi.lo
|
old.lo = mi.lo
|
||||||
}
|
}
|
||||||
@@ -651,7 +711,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -744,6 +804,18 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
s.apf = val == "1"
|
s.apf = val == "1"
|
||||||
case "apf_level":
|
case "apf_level":
|
||||||
s.apfLevel = atoiDefault(val, s.apfLevel)
|
s.apfLevel = atoiDefault(val, s.apfLevel)
|
||||||
|
case "wnb":
|
||||||
|
s.wnb = val == "1"
|
||||||
|
case "wnb_level":
|
||||||
|
s.wnbLevel = atoiDefault(val, s.wnbLevel)
|
||||||
|
case "rit_on":
|
||||||
|
s.rit = val == "1"
|
||||||
|
case "rit_freq":
|
||||||
|
s.ritFreq = atoiDefault(val, s.ritFreq)
|
||||||
|
case "xit_on":
|
||||||
|
s.xit = val == "1"
|
||||||
|
case "xit_freq":
|
||||||
|
s.xitFreq = atoiDefault(val, s.xitFreq)
|
||||||
case "filter_lo":
|
case "filter_lo":
|
||||||
s.filterLo = atoiDefault(val, s.filterLo)
|
s.filterLo = atoiDefault(val, s.filterLo)
|
||||||
case "filter_hi":
|
case "filter_hi":
|
||||||
@@ -803,24 +875,44 @@ func (f *Flex) ReadState() (RigState, error) {
|
|||||||
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
|
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
|
||||||
// use. Caller holds f.mu.
|
// use. Caller holds f.mu.
|
||||||
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
|
||||||
best, bestS := 1<<30, (*flexSlice)(nil)
|
// Iterate in ASCENDING index order — NEVER map-iteration order, which Go
|
||||||
for idx, s := range f.slices {
|
// randomises. When two slices transiently BOTH report active=1 (e.g. an
|
||||||
|
// external controller like DXHunter activates a slice on another band while
|
||||||
|
// ours still holds active, before SmartSDR sends active=0 to the old one),
|
||||||
|
// map order returned a RANDOM active slice each call → the operating frequency
|
||||||
|
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
|
||||||
|
// Deterministic order = the lowest-indexed active slice wins, stably.
|
||||||
|
firstInUse := -1
|
||||||
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
|
s := f.slices[idx]
|
||||||
if !s.inUse {
|
if !s.inUse {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if firstInUse < 0 {
|
||||||
|
firstInUse = idx
|
||||||
|
}
|
||||||
if s.active {
|
if s.active {
|
||||||
return idx, s
|
return idx, s
|
||||||
}
|
}
|
||||||
if idx < best {
|
|
||||||
best, bestS = idx, s
|
|
||||||
}
|
}
|
||||||
}
|
if firstInUse >= 0 {
|
||||||
if bestS != nil {
|
return firstInUse, f.slices[firstInUse]
|
||||||
return best, bestS
|
|
||||||
}
|
}
|
||||||
return -1, nil
|
return -1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortedSliceIdxLocked returns the slice indices in ascending order so every
|
||||||
|
// slice-selection helper is deterministic (map iteration is randomised). Caller
|
||||||
|
// holds f.mu.
|
||||||
|
func (f *Flex) sortedSliceIdxLocked() []int {
|
||||||
|
idxs := make([]int, 0, len(f.slices))
|
||||||
|
for idx := range f.slices {
|
||||||
|
idxs = append(idxs, idx)
|
||||||
|
}
|
||||||
|
sort.Ints(idxs)
|
||||||
|
return idxs
|
||||||
|
}
|
||||||
|
|
||||||
// activeSliceIndexLocked returns the slice index to send commands to (the main
|
// activeSliceIndexLocked returns the slice index to send commands to (the main
|
||||||
// slice, else 0). Caller holds f.mu.
|
// slice, else 0). Caller holds f.mu.
|
||||||
func (f *Flex) activeSliceIndexLocked() int {
|
func (f *Flex) activeSliceIndexLocked() int {
|
||||||
@@ -841,8 +933,8 @@ func sliceLetter(idx int) string {
|
|||||||
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
|
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
|
||||||
// Caller holds f.mu.
|
// Caller holds f.mu.
|
||||||
func (f *Flex) txSliceLocked() *flexSlice {
|
func (f *Flex) txSliceLocked() *flexSlice {
|
||||||
for _, s := range f.slices {
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
if s.inUse && s.tx {
|
if s := f.slices[idx]; s.inUse && s.tx {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -852,9 +944,11 @@ func (f *Flex) txSliceLocked() *flexSlice {
|
|||||||
// operatingLocked resolves the operator's slices: the MAIN (active) slice for the
|
// operatingLocked resolves the operator's slices: the MAIN (active) slice for the
|
||||||
// mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is
|
// mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is
|
||||||
// the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different
|
// the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different
|
||||||
// freq) — detected from the pair itself, NOT from which slice is active (the TX
|
// freq) AND the SAME split class (both PHONE, or both CW) — detected from the
|
||||||
// slice often steals focus right after "slice create", which must NOT read as
|
// pair itself, NOT from which slice is active (the TX slice often steals focus
|
||||||
// "no split"). A slice on another band is an independent receiver, ignored.
|
// right after "slice create", which must NOT read as "no split"). A slice on
|
||||||
|
// another band is an independent receiver, ignored; so is a same-band slice in a
|
||||||
|
// different mode (SSB + FT8) or a data mode (FT8/FT4/RTTY never split).
|
||||||
// Caller holds f.mu.
|
// Caller holds f.mu.
|
||||||
func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
|
func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
|
||||||
_, main = f.mainSliceLocked()
|
_, main = f.mainSliceLocked()
|
||||||
@@ -866,20 +960,31 @@ func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
|
|||||||
if bt == "" {
|
if bt == "" {
|
||||||
return main, nil, nil
|
return main, nil, nil
|
||||||
}
|
}
|
||||||
// RX = the active slice when it's a distinct same-band slice, else the first
|
// Split only applies to PHONE/CW; a data-mode TX slice never splits.
|
||||||
// other in-use same-band slice.
|
ct := flexSplitClass(txS.mode)
|
||||||
if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt {
|
if ct == "" {
|
||||||
|
return main, nil, nil
|
||||||
|
}
|
||||||
|
// A split partner is an in-use, same-band slice at a different freq in the
|
||||||
|
// SAME split class (so SSB + FT8 on one band is NOT a split).
|
||||||
|
sameSplit := func(s *flexSlice) bool {
|
||||||
|
return s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
|
||||||
|
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct
|
||||||
|
}
|
||||||
|
// RX = the active slice when it qualifies, else the first other qualifying
|
||||||
|
// same-band slice.
|
||||||
|
if sameSplit(main) {
|
||||||
rx = main
|
rx = main
|
||||||
} else {
|
} else {
|
||||||
for _, s := range f.slices {
|
for _, idx := range f.sortedSliceIdxLocked() {
|
||||||
if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt {
|
if s := f.slices[idx]; sameSplit(s) {
|
||||||
rx = s
|
rx = s
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if rx == nil {
|
if rx == nil {
|
||||||
return main, nil, nil // tx slice alone (simplex) → not split
|
return main, nil, nil // tx slice alone (simplex) or no same-mode partner → not split
|
||||||
}
|
}
|
||||||
return main, rx, txS
|
return main, rx, txS
|
||||||
}
|
}
|
||||||
@@ -927,6 +1032,15 @@ func (f *Flex) SetFrequency(hz int64) error {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
idx := f.activeSliceIndexLocked()
|
idx := f.activeSliceIndexLocked()
|
||||||
connected := f.conn != nil
|
connected := f.conn != nil
|
||||||
|
// Optimistically update the active slice's cached freq NOW, before the radio
|
||||||
|
// echoes the slice status back. Otherwise ReadState/FlexState keep reporting
|
||||||
|
// the OLD freq for the round-trip: the top display (optimistic liveFreqHz)
|
||||||
|
// jumped to the new band while the slice cache — which the FlexPanel and the
|
||||||
|
// Ultrabeam follow loop read — still showed the old one, so the antenna chased
|
||||||
|
// the stale value. The real echo confirms/corrects this a moment later.
|
||||||
|
if s := f.slices[idx]; s != nil {
|
||||||
|
s.freqHz = hz
|
||||||
|
}
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if !connected {
|
if !connected {
|
||||||
return fmt.Errorf("flex: not connected")
|
return fmt.Errorf("flex: not connected")
|
||||||
@@ -952,6 +1066,13 @@ func (f *Flex) SetMode(mode string) error {
|
|||||||
if fm == "" {
|
if fm == "" {
|
||||||
return fmt.Errorf("flex: unsupported mode %q", mode)
|
return fmt.Errorf("flex: unsupported mode %q", mode)
|
||||||
}
|
}
|
||||||
|
// Optimistically cache the new mode too (same reasoning as SetFrequency) so the
|
||||||
|
// panel reflects it immediately instead of lagging the radio's echo.
|
||||||
|
f.mu.Lock()
|
||||||
|
if s := f.slices[idx]; s != nil {
|
||||||
|
s.mode = fm
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
// "slice s <rx> mode=<m>" — set command per the SmartSDR API.
|
// "slice s <rx> mode=<m>" — set command per the SmartSDR API.
|
||||||
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
|
f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm))
|
||||||
return nil
|
return nil
|
||||||
@@ -975,8 +1096,30 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
|||||||
if color == "" {
|
if color == "" {
|
||||||
color = "#FFFFA500" // opaque orange default
|
color = "#FFFFA500" // opaque orange default
|
||||||
}
|
}
|
||||||
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=1800 trigger_action=Tune timestamp=%d",
|
life := s.LifetimeSec
|
||||||
float64(s.FreqHz)/1e6, call, color, time.Now().Unix())
|
if life <= 0 {
|
||||||
|
life = 1800 // default 30 min (cluster spots)
|
||||||
|
}
|
||||||
|
// De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already
|
||||||
|
// spotted gets its previous spot removed first — one live spot per call,
|
||||||
|
// refreshed, instead of a pile that all expire independently. NB: capture the
|
||||||
|
// old index under the lock but send OUTSIDE it — f.send() takes f.mu itself,
|
||||||
|
// and Go mutexes aren't reentrant (calling send while locked deadlocks the
|
||||||
|
// whole Flex goroutine → the radio drops OFFLINE).
|
||||||
|
upperCall := strings.ToUpper(s.Callsign)
|
||||||
|
f.mu.Lock()
|
||||||
|
old, hadOld := f.spotByCall[upperCall]
|
||||||
|
if hadOld {
|
||||||
|
delete(f.spotByCall, upperCall)
|
||||||
|
delete(f.spotCall, old)
|
||||||
|
delete(f.spotIdx, old)
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if hadOld {
|
||||||
|
f.send(fmt.Sprintf("spot remove %d", old))
|
||||||
|
}
|
||||||
|
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
||||||
|
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
||||||
if m := flexEncode(s.Mode); m != "" {
|
if m := flexEncode(s.Mode); m != "" {
|
||||||
cmd += " mode=" + m
|
cmd += " mode=" + m
|
||||||
}
|
}
|
||||||
@@ -1022,6 +1165,7 @@ func (f *Flex) ClearSpots() error {
|
|||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
f.spotIdx = map[int]bool{}
|
f.spotIdx = map[int]bool{}
|
||||||
f.spotCall = map[int]string{}
|
f.spotCall = map[int]string{}
|
||||||
|
f.spotByCall = map[string]int{}
|
||||||
connected := f.conn != nil
|
connected := f.conn != nil
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
if !connected {
|
if !connected {
|
||||||
@@ -1127,6 +1271,10 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
Mon: f.tx.mon,
|
Mon: f.tx.mon,
|
||||||
MonLevel: f.tx.monLevel,
|
MonLevel: f.tx.monLevel,
|
||||||
MicLevel: f.tx.micLevel,
|
MicLevel: f.tx.micLevel,
|
||||||
|
TXFilterLow: f.tx.filterLow,
|
||||||
|
TXFilterHigh: f.tx.filterHigh,
|
||||||
|
MicProfile: f.micProfile,
|
||||||
|
MicProfiles: f.micProfiles,
|
||||||
ATUStatus: f.tx.atuStatus,
|
ATUStatus: f.tx.atuStatus,
|
||||||
ATUMemories: f.tx.atuMemories,
|
ATUMemories: f.tx.atuMemories,
|
||||||
// CW keyer (defaults applied so the sliders show sane values pre-read).
|
// CW keyer (defaults applied so the sliders show sane values pre-read).
|
||||||
@@ -1177,6 +1325,12 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
st.ANFLevel = rx.anfLevel
|
st.ANFLevel = rx.anfLevel
|
||||||
st.APF = rx.apf
|
st.APF = rx.apf
|
||||||
st.APFLevel = rx.apfLevel
|
st.APFLevel = rx.apfLevel
|
||||||
|
st.WNB = rx.wnb
|
||||||
|
st.WNBLevel = rx.wnbLevel
|
||||||
|
st.RIT = rx.rit
|
||||||
|
st.RITFreq = rx.ritFreq
|
||||||
|
st.XIT = rx.xit
|
||||||
|
st.XITFreq = rx.xitFreq
|
||||||
st.FilterLo = rx.filterLo
|
st.FilterLo = rx.filterLo
|
||||||
st.FilterHi = rx.filterHi
|
st.FilterHi = rx.filterHi
|
||||||
st.RXAnt = rx.rxAnt
|
st.RXAnt = rx.rxAnt
|
||||||
@@ -1201,7 +1355,7 @@ func (f *Flex) FlexState() FlexTXState {
|
|||||||
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
|
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
mi := f.meterMeta[id]
|
mi := f.meterMeta[id]
|
||||||
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
|
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Slice: mi.slc, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
@@ -1245,6 +1399,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()
|
||||||
@@ -1352,8 +1514,30 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on))
|
|||||||
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
func (f *Flex) 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) SetWNBLevel(l int) error { return f.sendSlice("wnb_level", clampLevel(l)) }
|
||||||
|
|
||||||
// ── CW keyer controls (top-level "cw" commands) ──
|
// ── CW keyer controls (top-level "cw" commands) ──
|
||||||
|
|
||||||
@@ -1571,6 +1755,45 @@ func (f *Flex) SetMic(l int) error {
|
|||||||
return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l })
|
return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTXFilter sets the transmit-audio bandwidth (low + high cut, in Hz). SmartSDR
|
||||||
|
// clamps to legal values per mode; we just pass them through in one command so
|
||||||
|
// both edges move together.
|
||||||
|
func (f *Flex) SetTXFilter(low, high int) error {
|
||||||
|
if low < 0 {
|
||||||
|
low = 0
|
||||||
|
}
|
||||||
|
if high < low {
|
||||||
|
high = low
|
||||||
|
}
|
||||||
|
// Guard both status field names (the echo comes back as lo/hi, not
|
||||||
|
// filter_low/high) so the radio's lagging echo doesn't snap the inputs back.
|
||||||
|
f.mu.Lock()
|
||||||
|
f.txSetAt["hi"] = time.Now()
|
||||||
|
f.mu.Unlock()
|
||||||
|
return f.txSet(fmt.Sprintf("transmit set filter_low=%d filter_high=%d", low, high), "lo",
|
||||||
|
func(t *flexTX) { t.filterLow, t.filterHigh = low, high })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMicProfile loads a SmartSDR mic profile by name (quoted, as names may hold
|
||||||
|
// spaces). Optimistically caches the selection.
|
||||||
|
func (f *Flex) SetMicProfile(name string) error {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("flex: empty mic profile")
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
connected := f.conn != nil
|
||||||
|
if connected {
|
||||||
|
f.micProfile = name
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
if !connected {
|
||||||
|
return fmt.Errorf("flex: not connected")
|
||||||
|
}
|
||||||
|
f.send(fmt.Sprintf("profile mic load \"%s\"", name))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) ATUStart() error {
|
func (f *Flex) ATUStart() error {
|
||||||
if !f.connected() {
|
if !f.connected() {
|
||||||
return fmt.Errorf("flex: not connected")
|
return fmt.Errorf("flex: not connected")
|
||||||
@@ -1790,6 +2013,23 @@ func parseFloatDefault(s string, def float64) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
|
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
|
||||||
|
// flexSplitClass groups a raw Flex slice mode into a split-capable class. A
|
||||||
|
// genuine split (one RX freq, one TX freq, SAME mode) only makes sense for PHONE
|
||||||
|
// and CW pileups. Data modes (FT8/FT4/RTTY all report as DIGU/DIGL/RTTY on a
|
||||||
|
// Flex) and digital voice never operate split, so they return "" (not
|
||||||
|
// split-capable). Two same-band slices whose classes differ (e.g. SSB + FT8) are
|
||||||
|
// independent operations, not a split.
|
||||||
|
func flexSplitClass(rawMode string) string {
|
||||||
|
switch flexModeToADIF(rawMode) {
|
||||||
|
case "SSB", "AM", "FM":
|
||||||
|
return "PHONE"
|
||||||
|
case "CW":
|
||||||
|
return "CW"
|
||||||
|
default: // DATA, RTTY, DIGITALVOICE, "" → never split
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func flexModeToADIF(m string) string {
|
func flexModeToADIF(m string) string {
|
||||||
switch strings.ToUpper(strings.TrimSpace(m)) {
|
switch strings.ToUpper(strings.TrimSpace(m)) {
|
||||||
case "USB", "LSB":
|
case "USB", "LSB":
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A genuine split is two same-band slices in the SAME split class (both PHONE or
|
||||||
|
// both CW). Two same-band slices in different modes (SSB + FT8), or in a data
|
||||||
|
// mode (FT8/FT4/RTTY = DIGU/DIGL), must NOT read as split.
|
||||||
|
func TestOperatingSplitMode(t *testing.T) {
|
||||||
|
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
|
||||||
|
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
slices map[int]*flexSlice
|
||||||
|
wantSplit bool
|
||||||
|
}{
|
||||||
|
{"phone split (both USB)", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, false),
|
||||||
|
1: mk(14_200_000, "USB", false, true),
|
||||||
|
}, true},
|
||||||
|
{"CW split (both CW)", map[int]*flexSlice{
|
||||||
|
0: mk(7_010_000, "CW", true, false),
|
||||||
|
1: mk(7_025_000, "CW", false, true),
|
||||||
|
}, true},
|
||||||
|
{"SSB + FT8 same band → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, false),
|
||||||
|
1: mk(14_074_000, "DIGU", false, true),
|
||||||
|
}, false},
|
||||||
|
{"both FT8 (DIGU) same band → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_074_000, "DIGU", true, false),
|
||||||
|
1: mk(14_080_000, "DIGU", false, true),
|
||||||
|
}, false},
|
||||||
|
{"different bands → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, false),
|
||||||
|
1: mk(7_100_000, "LSB", false, true),
|
||||||
|
}, false},
|
||||||
|
{"simplex (single slice) → not split", map[int]*flexSlice{
|
||||||
|
0: mk(14_100_000, "USB", true, true),
|
||||||
|
}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
f := &Flex{slices: c.slices}
|
||||||
|
_, rx, tx := f.operatingLocked()
|
||||||
|
gotSplit := rx != nil && tx != nil
|
||||||
|
if gotSplit != c.wantSplit {
|
||||||
|
t.Errorf("%s: split=%v, want %v", c.name, gotSplit, c.wantSplit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// icomaudio.go — the NETWORK AUDIO stream (UDP 50003) for the Icom LAN protocol.
|
||||||
|
// It is the third stream alongside control (50001) and CI-V (50002): once the
|
||||||
|
// control login + conninfo (with rxenable=1) authorize audio, the rig streams RX
|
||||||
|
// audio here as data packets. This file dials/handshakes/keeps-alive that socket
|
||||||
|
// exactly like the CI-V stream (icomnet.go) — those parts are byte-for-byte the
|
||||||
|
// PROVEN transport — and hands each received audio payload to a sink callback
|
||||||
|
// (the app decodes it via an audio.Codec and plays it through the RX monitor).
|
||||||
|
//
|
||||||
|
// Reuses icomnet.go's helpers (icnCtrl, icnHandshake, icnPingReply, icnRecv,
|
||||||
|
// icnLocalID, icnLE) and the same seq/retransmit discipline.
|
||||||
|
//
|
||||||
|
// ⚠️ PAYLOAD OFFSET PENDING ON-RIG VERIFICATION. The stream framing (handshake,
|
||||||
|
// ping, idle, retransmit, common 16-byte header) is identical to CI-V and proven.
|
||||||
|
// The AUDIO data packet's inner layout — where the PCM starts and the datalen
|
||||||
|
// field — is reconstructed from wfview's audio_packet (ident@0x10, datalen@0x12,
|
||||||
|
// sendseq@0x14, audio@0x16) but NOT yet confirmed against a real 50003 capture.
|
||||||
|
// audioPump logs the first few raw packets (icaDumpFirst) so the offset can be
|
||||||
|
// confirmed/corrected on the first on-rig test without a packet capture, the same
|
||||||
|
// way the CI-V/scope framing was iterated. Nothing here can destabilize CAT: the
|
||||||
|
// audio stream is opt-in and entirely separate from control/CI-V.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// icaAudioOffset is where the PCM payload begins inside an audio data packet
|
||||||
|
// (wfview audio_packet: 16-byte common header + ident@0x10 + datalen@0x12 +
|
||||||
|
// sendseq@0x14 → audio@0x16). Isolated as a const so a capture-confirmed change
|
||||||
|
// is a one-line edit.
|
||||||
|
const icaAudioOffset = 0x16
|
||||||
|
|
||||||
|
// icaDumpFirst is how many initial audio packets to hex-dump to the debug log for
|
||||||
|
// offset verification. After the layout is confirmed on a real rig this can go to
|
||||||
|
// 0 (or the const above corrected).
|
||||||
|
const icaDumpFirst = 6
|
||||||
|
|
||||||
|
// icomAudio is the connected audio stream. RX only for now (Phase 4); TX (Phase
|
||||||
|
// 5) will add an encode+send path mirroring icomNet.Write.
|
||||||
|
type icomAudio struct {
|
||||||
|
conn *net.UDPConn
|
||||||
|
aID, aRemote uint32
|
||||||
|
|
||||||
|
sink func([]byte) // receives each raw audio payload (app decodes + plays)
|
||||||
|
|
||||||
|
// Receive-side retransmit (audio is a heavy stream, like the scope): track the
|
||||||
|
// rig's data-packet send seq and ask it to resend gaps, or the rig drops the
|
||||||
|
// session. Same mechanism as icomNet. Owned solely by audioPump → no lock.
|
||||||
|
rxHaveSeq bool
|
||||||
|
rxLastSeq uint16
|
||||||
|
rxMissing map[uint16]int
|
||||||
|
|
||||||
|
dumped int // packets hex-dumped so far (≤ icaDumpFirst)
|
||||||
|
lastRx atomic.Int64 // UnixNano of last packet (liveness)
|
||||||
|
|
||||||
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *icomAudio) markRx() { a.lastRx.Store(time.Now().UnixNano()) }
|
||||||
|
|
||||||
|
// Close tears the audio stream down (disconnect a few times; UDP is lossy).
|
||||||
|
func (a *icomAudio) Close() {
|
||||||
|
a.closeOnce.Do(func() {
|
||||||
|
close(a.done)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
_, _ = a.conn.Write(icnCtrl(0x05, 0, a.aID, a.aRemote)) // disconnect
|
||||||
|
time.Sleep(15 * time.Millisecond)
|
||||||
|
}
|
||||||
|
_ = a.conn.Close()
|
||||||
|
debugLog.Printf("icom audio: stream closed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialIcomAudio opens the audio UDP stream to rig:50003, binding LOCAL :50003
|
||||||
|
// (mirroring the civ stream's local :50002). The control conninfo (rxenable=1,
|
||||||
|
// audioport=50003) must already have authorized it. sink receives each raw audio
|
||||||
|
// payload. cancel aborts a slow dial (Stop/Start).
|
||||||
|
func dialIcomAudio(host string, sink func([]byte), cancel <-chan struct{}) (*icomAudio, error) {
|
||||||
|
araddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50003"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conn, err := net.DialUDP("udp4", &net.UDPAddr{Port: 50003}, araddr)
|
||||||
|
if err != nil {
|
||||||
|
debugLog.Printf("icom audio: cannot bind local :50003 (Remote Utility running?): %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
aID := icnLocalID(conn)
|
||||||
|
aRemote, err := icnHandshake(conn, aID, cancel)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
debugLog.Printf("icom audio: handshake FAILED: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = conn.SetReadBuffer(1 << 20)
|
||||||
|
a := &icomAudio{
|
||||||
|
conn: conn, aID: aID, aRemote: aRemote,
|
||||||
|
sink: sink,
|
||||||
|
rxMissing: make(map[uint16]int),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
a.markRx()
|
||||||
|
debugLog.Printf("icom audio: stream up (rig id 0x%08X) — awaiting RX audio", aRemote)
|
||||||
|
go a.audioPump()
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioPump drains the audio socket: replies to pings, sends idle keepalives,
|
||||||
|
// requests retransmits for lost packets, and hands each audio payload to sink.
|
||||||
|
func (a *icomAudio) audioPump() {
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
lastIdle := time.Now()
|
||||||
|
lastReq := time.Now()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-a.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
_ = a.conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
||||||
|
if k, err := a.conn.Read(buf); err == nil && k >= 16 {
|
||||||
|
a.markRx()
|
||||||
|
switch typ := icnLE.Uint16(buf[4:]); {
|
||||||
|
case typ == 0x07: // ping
|
||||||
|
_, _ = a.conn.Write(icnPingReply(buf[:k], a.aID, a.aRemote))
|
||||||
|
case typ == 0x01: // retransmit request from the rig (we send no tracked audio yet)
|
||||||
|
case typ == 0x05: // rig-initiated disconnect
|
||||||
|
debugLog.Printf("icom audio: rig sent DISCONNECT — audio stream dropped by the rig")
|
||||||
|
case typ == 0x00 && k > icaAudioOffset: // audio data packet
|
||||||
|
a.trackRxSeq(icnLE.Uint16(buf[6:]))
|
||||||
|
if a.dumped < icaDumpFirst {
|
||||||
|
a.dumped++
|
||||||
|
debugLog.Printf("icom audio raw #%d: len=%d head=% X", a.dumped, k, buf[:min(icaAudioOffset+8, k)])
|
||||||
|
}
|
||||||
|
if a.sink != nil {
|
||||||
|
payload := append([]byte(nil), buf[icaAudioOffset:k]...)
|
||||||
|
a.sink(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Since(lastIdle) > 100*time.Millisecond {
|
||||||
|
_, _ = a.conn.Write(icnCtrl(0x00, 0, a.aID, a.aRemote))
|
||||||
|
lastIdle = time.Now()
|
||||||
|
}
|
||||||
|
if time.Since(lastReq) > 100*time.Millisecond {
|
||||||
|
a.sendRetransmitReq()
|
||||||
|
lastReq = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackRxSeq / sendRetransmitReq mirror icomNet's receive-side retransmit exactly
|
||||||
|
// (audio is as loss-sensitive as the scope stream). Duplicated deliberately so
|
||||||
|
// the audio stream owns its own seq state with no shared locking.
|
||||||
|
func (a *icomAudio) trackRxSeq(seq uint16) {
|
||||||
|
if !a.rxHaveSeq {
|
||||||
|
a.rxHaveSeq = true
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch d := int16(seq - a.rxLastSeq); {
|
||||||
|
case d == 0:
|
||||||
|
case d < 0:
|
||||||
|
delete(a.rxMissing, seq)
|
||||||
|
case d == 1:
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
case int(d) <= icnMaxMissing:
|
||||||
|
for f := a.rxLastSeq + 1; f != seq; f++ {
|
||||||
|
a.rxMissing[f] = 0
|
||||||
|
}
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
default:
|
||||||
|
a.rxMissing = make(map[uint16]int)
|
||||||
|
a.rxLastSeq = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *icomAudio) sendRetransmitReq() {
|
||||||
|
if len(a.rxMissing) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(a.rxMissing) > icnMaxMissing {
|
||||||
|
a.rxMissing = make(map[uint16]int)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var seqs []uint16
|
||||||
|
for s, cnt := range a.rxMissing {
|
||||||
|
if cnt >= 4 {
|
||||||
|
delete(a.rxMissing, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.rxMissing[s] = cnt + 1
|
||||||
|
seqs = append(seqs, s)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(seqs) == 0:
|
||||||
|
return
|
||||||
|
case len(seqs) == 1:
|
||||||
|
_, _ = a.conn.Write(icnCtrl(0x01, seqs[0], a.aID, a.aRemote))
|
||||||
|
default:
|
||||||
|
b := make([]byte, 16+4*len(seqs))
|
||||||
|
icnLE.PutUint32(b[0:], uint32(len(b)))
|
||||||
|
icnLE.PutUint16(b[4:], 0x01)
|
||||||
|
icnLE.PutUint32(b[8:], a.aID)
|
||||||
|
icnLE.PutUint32(b[12:], a.aRemote)
|
||||||
|
off := 16
|
||||||
|
for _, s := range seqs {
|
||||||
|
icnLE.PutUint16(b[off:], s)
|
||||||
|
icnLE.PutUint16(b[off+2:], s)
|
||||||
|
off += 4
|
||||||
|
}
|
||||||
|
_, _ = a.conn.Write(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-8
@@ -36,7 +36,13 @@ var icnBE = binary.BigEndian
|
|||||||
// NewIcomNet builds an (unconnected) Icom backend whose transport is the network
|
// NewIcomNet builds an (unconnected) Icom backend whose transport is the network
|
||||||
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
|
// stream. host is the rig's IP/hostname; user/pass are the rig's Network User1
|
||||||
// credentials. Reuses the whole IcomSerial controller — only `open` differs.
|
// credentials. Reuses the whole IcomSerial controller — only `open` differs.
|
||||||
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *IcomSerial {
|
//
|
||||||
|
// audioSink (optional) enables the network RX audio stream (UDP 50003): when
|
||||||
|
// non-nil the conninfo asks the rig to stream audio and each received payload is
|
||||||
|
// passed to audioSink (the app decodes it via an audio.Codec and plays it). nil
|
||||||
|
// = CI-V only (the proven default). The audio stream is fully separate from CAT,
|
||||||
|
// so enabling it can't affect freq/mode/DSP control.
|
||||||
|
func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string, audioSink func([]byte)) *IcomSerial {
|
||||||
if civAddr <= 0 || civAddr > 0xFF {
|
if civAddr <= 0 || civAddr > 0xFF {
|
||||||
civAddr = 0x98 // IC-7610
|
civAddr = 0x98 // IC-7610
|
||||||
}
|
}
|
||||||
@@ -57,7 +63,7 @@ func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *Ic
|
|||||||
b.dialMu.Lock()
|
b.dialMu.Lock()
|
||||||
cancel := b.dialCancel
|
cancel := b.dialCancel
|
||||||
b.dialMu.Unlock()
|
b.dialMu.Unlock()
|
||||||
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel)
|
return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel, audioSink)
|
||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
@@ -130,6 +136,10 @@ type icomNet struct {
|
|||||||
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
// but link fine" (stay connected) from "link dead" (reconnect). See Alive().
|
||||||
lastRx atomic.Int64
|
lastRx atomic.Int64
|
||||||
|
|
||||||
|
// audio is the optional RX audio stream (UDP 50003). nil when audio is off.
|
||||||
|
// Torn down alongside the CI-V/control streams in Close.
|
||||||
|
audio *icomAudio
|
||||||
|
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
closeOnce sync.Once
|
closeOnce sync.Once
|
||||||
}
|
}
|
||||||
@@ -232,6 +242,9 @@ var icnTrace = false
|
|||||||
func (n *icomNet) Close() error {
|
func (n *icomNet) Close() error {
|
||||||
n.closeOnce.Do(func() {
|
n.closeOnce.Do(func() {
|
||||||
close(n.done)
|
close(n.done)
|
||||||
|
if n.audio != nil {
|
||||||
|
n.audio.Close()
|
||||||
|
}
|
||||||
// Tell the rig we're leaving so it frees its SINGLE control session at
|
// Tell the rig we're leaving so it frees its SINGLE control session at
|
||||||
// once. If it never gets a disconnect it holds the session for minutes and
|
// once. If it never gets a disconnect it holds the session for minutes and
|
||||||
// refuses every new login — which is why a lost link (or a hard app exit)
|
// refuses every new login — which is why a lost link (or a hard app exit)
|
||||||
@@ -475,8 +488,9 @@ func (n *icomNet) resend(seq uint16) {
|
|||||||
|
|
||||||
// ------------------------- connect -------------------------
|
// ------------------------- connect -------------------------
|
||||||
|
|
||||||
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}) (*icomNet, error) {
|
func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}, audioSink func([]byte)) (*icomNet, error) {
|
||||||
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X)", host, user, compName, rigAddr)
|
wantAudio := audioSink != nil
|
||||||
|
debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X, audio=%v)", host, user, compName, rigAddr, wantAudio)
|
||||||
// ---- control stream (50001): handshake → login → token → conninfo ----
|
// ---- control stream (50001): handshake → login → token → conninfo ----
|
||||||
craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
|
craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -562,7 +576,11 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
|
|||||||
rigMAC = make([]byte, 6)
|
rigMAC = make([]byte, 6)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003))
|
var rxEnable byte
|
||||||
|
if wantAudio {
|
||||||
|
rxEnable = 0x01 // ask the rig to stream RX audio on 50003
|
||||||
|
}
|
||||||
|
_, _ = ctrl.Write(icnConnInfo(cTracked, cInner, tokReq, cID, cRemote, token, user, rigMAC, 50002, 50003, rxEnable))
|
||||||
cTracked++
|
cTracked++
|
||||||
cInner++
|
cInner++
|
||||||
drainEnd := time.Now().Add(500 * time.Millisecond)
|
drainEnd := time.Now().Add(500 * time.Millisecond)
|
||||||
@@ -634,6 +652,18 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan
|
|||||||
|
|
||||||
go n.ctrlPump()
|
go n.ctrlPump()
|
||||||
go n.civPump()
|
go n.civPump()
|
||||||
|
|
||||||
|
// Optional RX audio stream (50003). The rig was told (conninfo rxEnable=1) to
|
||||||
|
// stream audio; open the socket + handshake now. A failure here is NON-fatal:
|
||||||
|
// CAT works without audio, so we log and continue rather than tear down a
|
||||||
|
// perfectly good control/CI-V session.
|
||||||
|
if wantAudio {
|
||||||
|
if a, err := dialIcomAudio(host, audioSink, cancel); err != nil {
|
||||||
|
debugLog.Printf("icom net: audio stream FAILED (CAT unaffected): %v", err)
|
||||||
|
} else {
|
||||||
|
n.audio = a
|
||||||
|
}
|
||||||
|
}
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,7 +802,7 @@ func icnTokenRenew(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) [
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte {
|
func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16, rxEnable byte) []byte {
|
||||||
b := make([]byte, 0x90)
|
b := make([]byte, 0x90)
|
||||||
icnLE.PutUint32(b[0:], 0x90)
|
icnLE.PutUint32(b[0:], 0x90)
|
||||||
icnLE.PutUint16(b[6:], seq)
|
icnLE.PutUint16(b[6:], seq)
|
||||||
@@ -788,8 +818,8 @@ func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, use
|
|||||||
copy(b[0x2a:0x30], rigMAC)
|
copy(b[0x2a:0x30], rigMAC)
|
||||||
copy(b[0x40:0x60], []byte("IC-7610"))
|
copy(b[0x40:0x60], []byte("IC-7610"))
|
||||||
copy(b[0x60:0x70], icnPasscode(user))
|
copy(b[0x60:0x70], icnPasscode(user))
|
||||||
b[0x70] = 0x00 // rxenable (audio off — CI-V only)
|
b[0x70] = rxEnable // rxenable: 1 opens the 50003 RX audio stream, 0 = CI-V only
|
||||||
b[0x71] = 0x00 // txenable
|
b[0x71] = 0x00 // txenable (Phase 5)
|
||||||
b[0x72] = 0x10 // rxcodec
|
b[0x72] = 0x10 // rxcodec
|
||||||
b[0x73] = 0x04 // txcodec
|
b[0x73] = 0x04 // txcodec
|
||||||
icnBE.PutUint32(b[0x74:], 16000)
|
icnBE.PutUint32(b[0x74:], 16000)
|
||||||
|
|||||||
@@ -210,17 +210,25 @@ func (b *IcomSerial) Connect() error {
|
|||||||
go b.netScopeFeeder(sc.ScopeChan(), b.readerDone)
|
go b.netScopeFeeder(sc.ScopeChan(), b.readerDone)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best-effort model identification: ask the rig for its own CI-V address.
|
// Best-effort model identification: ask the rig for its own CI-V address. The
|
||||||
|
// 0x19 ID read returns the rig's FACTORY default address (e.g. 0x94 for an
|
||||||
|
// IC-7300) regardless of the address it's currently OPERATING on — so it's the
|
||||||
|
// reliable model signal even when the user runs the rig at a non-default CI-V
|
||||||
|
// address (a common trick to make CAT "just work" at 0x98).
|
||||||
|
idAddr := b.rigAddr // fallback: the configured address if the ID read fails
|
||||||
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
|
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
|
||||||
if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||||
return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
|
return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
|
||||||
}); err == nil {
|
}); err == nil {
|
||||||
b.model = civ.ModelName(f.Data[1])
|
b.model = civ.ModelName(f.Data[1])
|
||||||
|
idAddr = f.Data[1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
|
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
|
||||||
// selector byte; single-scope rigs (IC-7300…) do not.
|
// selector byte; single-scope rigs (IC-7300…) do not. Decide from the
|
||||||
b.dualScope = b.rigAddr == 0x98 || b.rigAddr == 0xA2
|
// IDENTIFIED model, NOT the configured address: an IC-7300 run at 0x98 must
|
||||||
|
// still parse single-scope frames (this was the "scope blank on the 7300" bug).
|
||||||
|
b.dualScope = idAddr == 0x98 || idAddr == 0xA2
|
||||||
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network
|
// Defer the DSP snapshot until the rig actually answers CI-V. Over the network
|
||||||
// the rig may still be booting (or off) at Connect, so an immediate readDSP
|
// the rig may still be booting (or off) at Connect, so an immediate readDSP
|
||||||
// would time out and leave every control at 0 / off with no retry. ReadState
|
// would time out and leave every control at 0 / off with no retry. ReadState
|
||||||
@@ -590,7 +598,7 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if rawN < 4 {
|
if rawN < 24 {
|
||||||
rawN++
|
rawN++
|
||||||
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
|
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
|
||||||
}
|
}
|
||||||
@@ -696,6 +704,13 @@ func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) {
|
|||||||
// and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader
|
// and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader
|
||||||
// routes every 0x27 frame to scopeLoop.
|
// routes every 0x27 frame to scopeLoop.
|
||||||
func (b *IcomSerial) SetScope(on bool) error {
|
func (b *IcomSerial) SetScope(on bool) error {
|
||||||
|
if on {
|
||||||
|
// Context for the scope-diagnostic log: which rig + whether we expect the
|
||||||
|
// dual-scope (main/sub) frame layout. If the IC-7300 (single scope) streams
|
||||||
|
// but nothing shows, compare its `icom scope raw` frames against this.
|
||||||
|
applog.Printf("icom scope: enable on rig=%q addr=0x%02X dualScope=%v (expect %s frame layout)",
|
||||||
|
b.model, b.rigAddr, b.dualScope, map[bool]string{true: "27 00 [MS] [seq] [total] …", false: "27 00 [seq] [total] …"}[b.dualScope])
|
||||||
|
}
|
||||||
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
|
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
|
||||||
// continue rather than abort the second command.
|
// continue rather than abort the second command.
|
||||||
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
|
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
|
||||||
|
|||||||
@@ -414,6 +414,16 @@ func (o *OmniRig) SetPTT(on bool) error {
|
|||||||
debugLog.Printf("OmniRig.SetPTT error: %v", err)
|
debugLog.Printf("OmniRig.SetPTT error: %v", err)
|
||||||
return fmt.Errorf("set Tx=%s: %w", name, err)
|
return fmt.Errorf("set Tx=%s: %w", name, err)
|
||||||
}
|
}
|
||||||
|
// Read the Tx param straight back. OmniRig is async — this may still show the
|
||||||
|
// previous value for a poll cycle — but if a key/unkey NEVER changes it, the
|
||||||
|
// write was coalesced or the rig isn't honouring PM_TX/PM_RX (wrong .ini).
|
||||||
|
if v, err := oleutil.GetProperty(o.rig, "Tx"); err == nil {
|
||||||
|
txState := "PM_RX"
|
||||||
|
if v.Val&pmTX != 0 {
|
||||||
|
txState = "PM_TX"
|
||||||
|
}
|
||||||
|
debugLog.Printf("OmniRig.SetPTT: Tx readback = 0x%X (%s)", v.Val, txState)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,23 @@ func (db *DB) Resolve(call string, date time.Time) (Exception, bool) {
|
|||||||
return Exception{}, false
|
return Exception{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasException reports whether the callsign has ANY full-call exception (at any
|
||||||
|
// date). Used to tell a genuinely date-sensitive call (G1T: Scotland only from
|
||||||
|
// 2024) — where cty.dat's date-blind exact-call override is wrong for older
|
||||||
|
// QSOs — from an ordinary call cty.dat handles fine.
|
||||||
|
func (db *DB) HasException(call string) bool {
|
||||||
|
if db == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(call))
|
||||||
|
for _, key := range candidates(c) {
|
||||||
|
if len(db.exceptions[key]) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// candidates yields the call and a version with one trailing affix removed.
|
// candidates yields the call and a version with one trailing affix removed.
|
||||||
func candidates(c string) []string {
|
func candidates(c string) []string {
|
||||||
out := []string{c}
|
out := []string{c}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package clublog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func d(s string) time.Time {
|
||||||
|
t, _ := time.Parse("2006-01-02", s)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// G1T is a contest call: Scotland ONLY from 2024-02-21; before that it's England
|
||||||
|
// by prefix. cty.dat's date-blind "=G1T → Scotland" override is wrong for a 2012
|
||||||
|
// QSO — the date-aware ClubLog cascade must give England then, Scotland now.
|
||||||
|
func TestG1TDatedException(t *testing.T) {
|
||||||
|
db := &DB{
|
||||||
|
exceptions: map[string][]Exception{
|
||||||
|
"G1T": {{Call: "G1T", Entity: "Scotland", ADIF: 279, Start: d("2024-02-21")}},
|
||||||
|
},
|
||||||
|
prefixes: map[string][]Exception{
|
||||||
|
"G": {{Entity: "England", ADIF: 223}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2012 QSO: exception doesn't cover it, but the call HAS an exception →
|
||||||
|
// caller should use the prefix (England).
|
||||||
|
if _, ok := db.Resolve("G1T", d("2012-07-29")); ok {
|
||||||
|
t.Fatalf("2012: exception should NOT cover a pre-2024 date")
|
||||||
|
}
|
||||||
|
if !db.HasException("G1T") {
|
||||||
|
t.Fatalf("HasException(G1T) should be true")
|
||||||
|
}
|
||||||
|
pe, pok := db.ResolvePrefix("G1T", d("2012-07-29"))
|
||||||
|
if !pok || pe.Entity != "England" {
|
||||||
|
t.Fatalf("2012 prefix: got %q ok=%v, want England", pe.Entity, pok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2025 QSO: the exception covers it → Scotland.
|
||||||
|
e, ok := db.Resolve("G1T", d("2025-01-01"))
|
||||||
|
if !ok || e.Entity != "Scotland" {
|
||||||
|
t.Fatalf("2025: got %q ok=%v, want Scotland", e.Entity, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ordinary call with no exception → HasException false (caller leaves it
|
||||||
|
// to cty.dat).
|
||||||
|
if db.HasException("G4ABC") {
|
||||||
|
t.Fatalf("HasException(G4ABC) should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,17 @@ func (m *Manager) Info() (date string, count int) {
|
|||||||
return m.db.Date(), m.db.Count()
|
return m.db.Date(), m.db.Count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NeedsRefresh reports whether the cached country file is missing or older than
|
||||||
|
// maxAge — so the caller re-downloads it to pick up newly-added date-ranged
|
||||||
|
// exceptions (e.g. an event call's fresh SARDINIA period).
|
||||||
|
func (m *Manager) NeedsRefresh(maxAge time.Duration) bool {
|
||||||
|
fi, err := os.Stat(m.Path())
|
||||||
|
if err != nil {
|
||||||
|
return true // missing
|
||||||
|
}
|
||||||
|
return time.Since(fi.ModTime()) > maxAge
|
||||||
|
}
|
||||||
|
|
||||||
// EnsureLoaded loads the cached file into memory if present. Does NOT download.
|
// EnsureLoaded loads the cached file into memory if present. Does NOT download.
|
||||||
func (m *Manager) EnsureLoaded() error {
|
func (m *Manager) EnsureLoaded() error {
|
||||||
if m.Loaded() {
|
if m.Loaded() {
|
||||||
@@ -125,3 +136,19 @@ func (m *Manager) Resolve(call string, date time.Time) (Exception, bool) {
|
|||||||
}
|
}
|
||||||
return db.Resolve(call, date)
|
return db.Resolve(call, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolvePrefix resolves a call by ClubLog's date-ranged PREFIX table.
|
||||||
|
func (m *Manager) ResolvePrefix(call string, date time.Time) (Exception, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
db := m.db
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return db.ResolvePrefix(call, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasException reports whether the call has any full-call exception (any date).
|
||||||
|
func (m *Manager) HasException(call string) bool {
|
||||||
|
m.mu.RLock()
|
||||||
|
db := m.db
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return db.HasException(call)
|
||||||
|
}
|
||||||
|
|||||||
+139
-9
@@ -61,6 +61,11 @@ type Spot struct {
|
|||||||
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
||||||
ReceivedAt time.Time `json:"received_at"`
|
ReceivedAt time.Time `json:"received_at"`
|
||||||
Raw string `json:"raw"`
|
Raw string `json:"raw"`
|
||||||
|
// Historical marks a spot recovered from a SH/DX table rather than heard live.
|
||||||
|
// It belongs in the grid, but must NOT fire alerts or reach the panadapter:
|
||||||
|
// replaying 100 past spots would spam both, and a station spotted three hours
|
||||||
|
// ago is not on the air now.
|
||||||
|
Historical bool `json:"historical,omitempty"`
|
||||||
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
||||||
POTAName string `json:"pota_name,omitempty"` // park name
|
POTAName string `json:"pota_name,omitempty"` // park name
|
||||||
}
|
}
|
||||||
@@ -92,6 +97,21 @@ type ServerStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// session is one telnet connection bound to a single server config.
|
// session is one telnet connection bound to a single server config.
|
||||||
|
// Line is one raw line of cluster traffic: everything the server says, plus the
|
||||||
|
// commands we send (echoed, so the console reads as a conversation).
|
||||||
|
//
|
||||||
|
// Spots are PARSED OUT of this stream — but everything else used to be silently
|
||||||
|
// dropped. You could type SH/DX/100 or WHO and never see the answer, which made
|
||||||
|
// the command box look broken. The raw stream is the fix: the console shows what
|
||||||
|
// the server actually said, spot or not.
|
||||||
|
type Line struct {
|
||||||
|
ServerID int64 `json:"server_id"`
|
||||||
|
ServerName string `json:"server_name"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Sent bool `json:"sent"` // true = a command WE sent
|
||||||
|
At string `json:"at"` // HH:MM:SS, local
|
||||||
|
}
|
||||||
|
|
||||||
// Internal — callers use Manager. The onStatus callback is fire-and-
|
// Internal — callers use Manager. The onStatus callback is fire-and-
|
||||||
// forget: it tells the manager something changed; the frontend fetches
|
// forget: it tells the manager something changed; the frontend fetches
|
||||||
// the new aggregate via Status() rather than receiving per-server diffs.
|
// the new aggregate via Status() rather than receiving per-server diffs.
|
||||||
@@ -99,6 +119,7 @@ type session struct {
|
|||||||
cfg ServerConfig
|
cfg ServerConfig
|
||||||
login string
|
login string
|
||||||
onSpot func(Spot)
|
onSpot func(Spot)
|
||||||
|
onLine func(Line)
|
||||||
onStatus func()
|
onStatus func()
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -117,6 +138,7 @@ type Manager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
sessions map[int64]*session
|
sessions map[int64]*session
|
||||||
onSpot func(Spot)
|
onSpot func(Spot)
|
||||||
|
onLine func(Line)
|
||||||
onStatus func()
|
onStatus func()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,10 +146,11 @@ type Manager struct {
|
|||||||
// spot (with the source server filled in). emitStatusChanged is called
|
// spot (with the source server filled in). emitStatusChanged is called
|
||||||
// whenever ANY server's status changes — the frontend then re-fetches
|
// whenever ANY server's status changes — the frontend then re-fetches
|
||||||
// the aggregate Status() via a Wails binding.
|
// the aggregate Status() via a Wails binding.
|
||||||
func NewManager(emitSpot func(Spot), emitStatusChanged func()) *Manager {
|
func NewManager(emitSpot func(Spot), emitStatusChanged func(), emitLine func(Line)) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
sessions: make(map[int64]*session),
|
sessions: make(map[int64]*session),
|
||||||
onSpot: emitSpot,
|
onSpot: emitSpot,
|
||||||
|
onLine: emitLine,
|
||||||
onStatus: emitStatusChanged,
|
onStatus: emitStatusChanged,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,6 +178,7 @@ func (m *Manager) StartServer(cfg ServerConfig, login string) {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
login: login,
|
login: login,
|
||||||
onSpot: m.onSpot,
|
onSpot: m.onSpot,
|
||||||
|
onLine: m.onLine,
|
||||||
onStatus: m.emitStatus,
|
onStatus: m.emitStatus,
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
doneCh: make(chan struct{}),
|
doneCh: make(chan struct{}),
|
||||||
@@ -242,6 +266,12 @@ func (s *session) send(cmd string) error {
|
|||||||
}
|
}
|
||||||
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||||
_, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n"))
|
_, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n"))
|
||||||
|
if err == nil {
|
||||||
|
// Echo it into the console, so the operator sees WHAT was sent and can pair
|
||||||
|
// it with the reply that follows. A console that only shows one side of the
|
||||||
|
// conversation is barely better than none.
|
||||||
|
s.emitLine(cmd, true)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,8 +383,18 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init commands: fire 1s after login goes through. Each command on
|
// Init commands, once per connection (so they replay after a reconnect).
|
||||||
// its own line; blank lines and "//" comments are skipped.
|
//
|
||||||
|
// Timing: the cluster needs a moment after login before it will take commands,
|
||||||
|
// hence the lead-in wait; then they are PACED, because a DXSpider/AR-Cluster
|
||||||
|
// happily swallows a burst of back-to-back lines and silently ignores half of
|
||||||
|
// them. A fast-but-dropped command is worse than a slow one.
|
||||||
|
//
|
||||||
|
// They go through s.send() — NOT a raw conn.Write, which is what they used to
|
||||||
|
// do. That matters for two reasons: the raw write skipped the write deadline
|
||||||
|
// (a wedged server could hang this goroutine forever), and it skipped the
|
||||||
|
// console echo, so there was no way to SEE whether your init commands had
|
||||||
|
// actually been applied. Now you watch them go out and the reply come back.
|
||||||
initFired := false
|
initFired := false
|
||||||
fireInitCommands := func() {
|
fireInitCommands := func() {
|
||||||
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
|
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
|
||||||
@@ -362,10 +402,9 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}
|
}
|
||||||
initFired = true
|
initFired = true
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(1 * time.Second)
|
time.Sleep(initCommandLeadIn)
|
||||||
for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
|
for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
|
||||||
line = strings.TrimRight(line, "\r")
|
line = strings.TrimSpace(strings.TrimRight(line, "\r"))
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" || strings.HasPrefix(line, "//") {
|
if line == "" || strings.HasPrefix(line, "//") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -374,8 +413,11 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
_, _ = conn.Write([]byte(line + "\r\n"))
|
if err := s.send(line); err != nil {
|
||||||
time.Sleep(500 * time.Millisecond)
|
s.emitLine("init command failed: "+line+" — "+err.Error(), false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(initCommandGap)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -444,12 +486,28 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
fireInitCommands()
|
fireInitCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
if spot, ok := parseSpot(line); ok {
|
// EVERY line goes to the console — spot or not. This is the whole point:
|
||||||
|
// SH/DX, WHO, the MOTD and error replies are not spots, and dropping them
|
||||||
|
// (which is what happened before) made the command box look inert.
|
||||||
|
s.emitLine(line, false)
|
||||||
|
|
||||||
|
// Live broadcast first; then the SH/DX table form. Without the second, a
|
||||||
|
// SH/DX reply arrived, matched nothing, and vanished — which is exactly why
|
||||||
|
// "SH/DX/100" looked like it did nothing at all.
|
||||||
|
spot, ok := parseSpot(line)
|
||||||
|
if !ok {
|
||||||
|
spot, ok = parseShowDX(line)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
spot.SourceID = s.cfg.ID
|
spot.SourceID = s.cfg.ID
|
||||||
spot.SourceName = s.cfg.Name
|
spot.SourceName = s.cfg.Name
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
// Historical spots are a replay of the past, not new traffic — counting
|
||||||
|
// them would inflate the server's "spots received" figure.
|
||||||
|
if !spot.Historical {
|
||||||
s.spotsCnt++
|
s.spotsCnt++
|
||||||
s.status.SpotsCount = s.spotsCnt
|
s.status.SpotsCount = s.spotsCnt
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if s.onSpot != nil {
|
if s.onSpot != nil {
|
||||||
s.onSpot(spot)
|
s.onSpot(spot)
|
||||||
@@ -458,6 +516,21 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emitLine hands one line of traffic to the console. Blank lines are dropped —
|
||||||
|
// clusters send plenty and they'd only pad the scrollback.
|
||||||
|
func (s *session) emitLine(text string, sent bool) {
|
||||||
|
if s.onLine == nil || strings.TrimSpace(text) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.onLine(Line{
|
||||||
|
ServerID: s.cfg.ID,
|
||||||
|
ServerName: s.cfg.Name,
|
||||||
|
Text: strings.TrimRight(text, "\r\n"),
|
||||||
|
Sent: sent,
|
||||||
|
At: time.Now().Format("15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- parsing ----------
|
// ---------- parsing ----------
|
||||||
|
|
||||||
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
||||||
@@ -465,6 +538,63 @@ 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+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Pacing for the per-server init commands.
|
||||||
|
//
|
||||||
|
// A cluster is not ready to take commands the instant the login line goes out —
|
||||||
|
// it is still printing its banner — so we wait before the first one. And they are
|
||||||
|
// spaced, because DXSpider / AR-Cluster will happily accept a burst of
|
||||||
|
// back-to-back lines and silently apply only some of them: the connection stays
|
||||||
|
// up, no error is returned, your filters just aren't set. A command that is
|
||||||
|
// dropped is far worse than one that is slow.
|
||||||
|
const (
|
||||||
|
initCommandLeadIn = 1500 * time.Millisecond
|
||||||
|
initCommandGap = 700 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// showDXRE matches the reply to SH/DX — which is a TABLE, not the "DX de …"
|
||||||
|
// broadcast format, and therefore matched nothing at all:
|
||||||
|
//
|
||||||
|
// 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>
|
||||||
|
// freq dxcall date time comment <spotter>
|
||||||
|
//
|
||||||
|
// This is why "SH/DX/100 does nothing": the 100 lines arrive, fail the broadcast
|
||||||
|
// regex, and get dropped. The decimal point in the frequency is required — it is
|
||||||
|
// what keeps ordinary prose out of the spot grid.
|
||||||
|
var showDXRE = regexp.MustCompile(
|
||||||
|
`^\s*(\d{3,7}\.\d+)\s+([A-Z0-9]{1,3}[0-9][A-Z0-9/]*)\s+(\d{1,2}-[A-Za-z]{3}-\d{4})\s+(\d{4})Z?\s*(.*?)\s*(?:<\s*([A-Z0-9/#\-]+)\s*>)?\s*$`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseShowDX turns one line of a SH/DX table into a Spot. The result is flagged
|
||||||
|
// Historical: these are PAST spots, so they belong in the grid but must not fire
|
||||||
|
// alerts or land on the panadapter — replaying 100 of them would spam both, and a
|
||||||
|
// station spotted three hours ago is not on the air now.
|
||||||
|
func parseShowDX(line string) (Spot, bool) {
|
||||||
|
if strings.Contains(strings.ToUpper(line), "DX DE") {
|
||||||
|
return Spot{}, false // that's the broadcast form; spotRE owns it
|
||||||
|
}
|
||||||
|
m := showDXRE.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
return Spot{}, false
|
||||||
|
}
|
||||||
|
khz, err := strconv.ParseFloat(m[1], 64)
|
||||||
|
if err != nil || khz <= 0 {
|
||||||
|
return Spot{}, false
|
||||||
|
}
|
||||||
|
hz := int64(khz * 1000)
|
||||||
|
return Spot{
|
||||||
|
Spotter: strings.ToUpper(m[6]),
|
||||||
|
DXCall: strings.ToUpper(m[2]),
|
||||||
|
FreqKHz: khz,
|
||||||
|
FreqHz: hz,
|
||||||
|
Band: bandFromHz(hz),
|
||||||
|
Comment: strings.TrimSpace(m[5]),
|
||||||
|
TimeUTC: m[4] + "Z",
|
||||||
|
ReceivedAt: time.Now(),
|
||||||
|
Raw: line,
|
||||||
|
Historical: true,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
func parseSpot(line string) (Spot, bool) {
|
func parseSpot(line string) (Spot, bool) {
|
||||||
m := spotRE.FindStringSubmatch(line)
|
m := spotRE.FindStringSubmatch(line)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
|
|||||||
@@ -63,3 +63,57 @@ func TestParseSpotRejectsNoise(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The reply to SH/DX is a TABLE, not the "DX de …" broadcast — a completely
|
||||||
|
// different shape. Because only the broadcast form was parsed, a SH/DX/100 reply
|
||||||
|
// arrived, matched nothing and was dropped: the command looked like it did
|
||||||
|
// nothing at all. These lines must now land in the grid, flagged Historical.
|
||||||
|
func TestParseShowDX(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
line string
|
||||||
|
call string
|
||||||
|
khz float64
|
||||||
|
spotter string
|
||||||
|
}{
|
||||||
|
{" 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>", "EA8DHH", 14195.0, "F5ABC"},
|
||||||
|
{" 7005.5 RA3XYZ 14-Jul-2026 0912Z <DL1ABC>", "RA3XYZ", 7005.5, "DL1ABC"},
|
||||||
|
{"21025.0 VK9/DL2XYZ 1-Jan-2026 0001Z up 2 <JA1ABC>", "VK9/DL2XYZ", 21025.0, "JA1ABC"},
|
||||||
|
// No spotter brackets — still a spot, just without a DE.
|
||||||
|
{" 50313.0 IK0ABC 3-Jul-2026 1500Z FT8", "IK0ABC", 50313.0, ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := parseShowDX(c.line)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("parseShowDX(%q) failed — the SH/DX reply would be dropped again", c.line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got.DXCall != c.call || got.FreqKHz != c.khz || got.Spotter != c.spotter {
|
||||||
|
t.Errorf("parseShowDX(%q) = call %q / %.1f / de %q, want %q / %.1f / %q",
|
||||||
|
c.line, got.DXCall, got.FreqKHz, got.Spotter, c.call, c.khz, c.spotter)
|
||||||
|
}
|
||||||
|
if !got.Historical {
|
||||||
|
t.Errorf("parseShowDX(%q): must be flagged Historical — otherwise 100 replayed spots fire 100 alerts", c.line)
|
||||||
|
}
|
||||||
|
if got.Band == "" {
|
||||||
|
t.Errorf("parseShowDX(%q): band not derived from %.1f kHz", c.line, c.khz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SH/DX parser must not swallow ordinary cluster prose — a console full of
|
||||||
|
// chatter turned into fake spots would be worse than no parser at all.
|
||||||
|
func TestParseShowDXRejectsNoise(t *testing.T) {
|
||||||
|
noise := []string{
|
||||||
|
"DX de F5ABC: 14195.0 EA8DHH CQ DX 1234Z", // the broadcast form: spotRE owns it
|
||||||
|
"Hello and welcome to the DXSpider cluster",
|
||||||
|
"WWV de VE7CC <18Z> : SFI=110, A=16, K=2",
|
||||||
|
"F4BPO de GB7DXC 12-Jul-2026 2130Z dxspider >",
|
||||||
|
"",
|
||||||
|
"There are 42 users online",
|
||||||
|
}
|
||||||
|
for _, l := range noise {
|
||||||
|
if s, ok := parseShowDX(l); ok {
|
||||||
|
t.Errorf("parseShowDX(%q) wrongly produced a spot: %+v", l, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsConnLost reports whether err means the database was UNREACHABLE (network
|
||||||
|
// down, server gone, dead pooled connection) as opposed to a legitimate
|
||||||
|
// server-side SQL error (constraint violation, bad data, syntax).
|
||||||
|
//
|
||||||
|
// This distinction is the linchpin of the offline safety net: a QSO is parked in
|
||||||
|
// the local ADIF outbox ONLY on a connection loss. If we queued on any error, a
|
||||||
|
// genuine data bug would silently vanish into the file instead of being
|
||||||
|
// reported — the worst possible outcome.
|
||||||
|
//
|
||||||
|
// A *mysql.MySQLError means the SERVER answered and rejected us, so it is never
|
||||||
|
// a connection loss, whatever its code.
|
||||||
|
func IsConnLost(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The server responded → a real SQL error, not a lost link.
|
||||||
|
var me *mysql.MySQLError
|
||||||
|
if errors.As(err, &me) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, driver.ErrBadConn) || errors.Is(err, sql.ErrConnDone) || errors.Is(err, sql.ErrTxDone) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var ne net.Error
|
||||||
|
if errors.As(err, &ne) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var oe *net.OpError
|
||||||
|
if errors.As(err, &oe) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// The MySQL driver reports a few of these as plain strings.
|
||||||
|
s := strings.ToLower(err.Error())
|
||||||
|
for _, p := range []string{
|
||||||
|
"invalid connection",
|
||||||
|
"bad connection",
|
||||||
|
"connection refused",
|
||||||
|
"connection reset",
|
||||||
|
"broken pipe",
|
||||||
|
"no such host",
|
||||||
|
"i/o timeout",
|
||||||
|
"dial tcp",
|
||||||
|
"unexpected eof",
|
||||||
|
"driver: bad connection",
|
||||||
|
"can't connect",
|
||||||
|
"network is unreachable",
|
||||||
|
"host is unreachable",
|
||||||
|
} {
|
||||||
|
if strings.Contains(s, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
+13
-5
@@ -339,17 +339,25 @@ func normalizeCallsign(s string) string {
|
|||||||
// which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not
|
// which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not
|
||||||
// Ecuador). This is the same class of rule as KG4 and /MM.
|
// Ecuador). This is the same class of rule as KG4 and /MM.
|
||||||
if areaDigit != 0 {
|
if areaDigit != 0 {
|
||||||
main = replaceFirstDigit(main, areaDigit)
|
main = replaceAreaDigit(main, areaDigit)
|
||||||
}
|
}
|
||||||
return main
|
return main
|
||||||
}
|
}
|
||||||
|
|
||||||
// replaceFirstDigit substitutes the first 0-9 digit of a call with d (used to
|
// replaceAreaDigit substitutes the CALL-AREA digit of a call with d (used to
|
||||||
// apply a "/N" call-area change). Returns the call unchanged if it has no digit.
|
// apply a "/N" call-area change). The area digit is the first digit that comes
|
||||||
func replaceFirstDigit(call string, d byte) string {
|
// AFTER a prefix letter — NOT a leading digit that is part of a digit-first
|
||||||
|
// prefix (7X, 3A, 4X, 9A, 2E…). So 7X2ARA/4 → 7X4ARA (still Algeria, area 4),
|
||||||
|
// NOT 4X2ARA (which is Israel — the old first-digit bug). Returns the call
|
||||||
|
// unchanged if it has no such digit.
|
||||||
|
func replaceAreaDigit(call string, d byte) string {
|
||||||
b := []byte(call)
|
b := []byte(call)
|
||||||
|
seenLetter := false
|
||||||
for i := range b {
|
for i := range b {
|
||||||
if b[i] >= '0' && b[i] <= '9' {
|
switch {
|
||||||
|
case b[i] >= 'A' && b[i] <= 'Z':
|
||||||
|
seenLetter = true
|
||||||
|
case b[i] >= '0' && b[i] <= '9' && seenLetter:
|
||||||
b[i] = d
|
b[i] = d
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,9 @@ func TestNormalize(t *testing.T) {
|
|||||||
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
||||||
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
||||||
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
||||||
|
"7X2ARA/4": "7X4ARA", // "/4" changes the AREA digit (2→4), NOT the 7 of the 7X prefix (was wrongly 4X2ARA = Israel)
|
||||||
|
"3A2ABC/6": "3A6ABC", // digit-first prefix 3A: area digit is the 2, not the 3
|
||||||
|
"4X1AB/2": "4X2AB", // 4X (Israel) area 1→2, keep the leading 4
|
||||||
"DL/F4BPO": "DL",
|
"DL/F4BPO": "DL",
|
||||||
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
|
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
|
||||||
"MM/LY3X/P": "MM",
|
"MM/LY3X/P": "MM",
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ const clublogRealtimeURL = "https://clublog.org/realtime.php"
|
|||||||
// N QSOs is one HTTP request instead of N realtime.php calls.
|
// N QSOs is one HTTP request instead of N realtime.php calls.
|
||||||
const clublogBatchURL = "https://clublog.org/putlogs.php"
|
const clublogBatchURL = "https://clublog.org/putlogs.php"
|
||||||
|
|
||||||
|
// clublogUserAgent identifies OpsLog to Club Log. Go's default
|
||||||
|
// "Go-http-client/1.1" User-Agent is blocked by Club Log's web front end (nginx
|
||||||
|
// returns 403 Forbidden before the request reaches the app), so every request
|
||||||
|
// must send a real, app-identifying User-Agent.
|
||||||
|
const clublogUserAgent = "OpsLog/1.0 (+https://github.com/GregTroar/OpsLog)"
|
||||||
|
|
||||||
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
|
||||||
// requires an api parameter that identifies the client software (not the
|
// requires an api parameter that identifies the client software (not the
|
||||||
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
// user) — the same way Log4OM embeds its own key — so we ship it baked in
|
||||||
@@ -122,6 +128,7 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
|
|||||||
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
req.Header.Set("User-Agent", clublogUserAgent)
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = &http.Client{Timeout: 120 * time.Second}
|
client = &http.Client{Timeout: 120 * time.Second}
|
||||||
}
|
}
|
||||||
@@ -135,10 +142,63 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
|
|||||||
if resp.StatusCode == http.StatusOK {
|
if resp.StatusCode == http.StatusOK {
|
||||||
return UploadResult{OK: true, Message: msg}, nil
|
return UploadResult{OK: true, Message: msg}, nil
|
||||||
}
|
}
|
||||||
if msg == "" {
|
diag := clublogFailureDiag(resp, msg)
|
||||||
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
return UploadResult{OK: false, Message: diag}, fmt.Errorf("clublog: batch upload failed: %s", diag)
|
||||||
}
|
}
|
||||||
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: batch upload failed: %s", msg)
|
|
||||||
|
// clublogFailureDiag turns a non-200 response into a readable one-liner that
|
||||||
|
// names WHO blocked the request — Club Log's app vs. an intermediary (Cloudflare,
|
||||||
|
// Sucuri, a corporate proxy, an antivirus TLS shim). A bare "403 Forbidden nginx"
|
||||||
|
// page means the request never reached the app; the fingerprint headers below
|
||||||
|
// tell the operator whether it's Club Log's own WAF or something on their side.
|
||||||
|
func clublogFailureDiag(resp *http.Response, body string) string {
|
||||||
|
server := strings.TrimSpace(resp.Header.Get("Server"))
|
||||||
|
// Notable intermediary fingerprints — presence points at the culprit.
|
||||||
|
fp := []string{}
|
||||||
|
for _, h := range []string{"CF-RAY", "CF-Mitigated", "X-Sucuri-ID", "X-Sucuri-Block", "X-Squid-Error", "Via", "X-Cache", "Retry-After"} {
|
||||||
|
if v := strings.TrimSpace(resp.Header.Get(h)); v != "" {
|
||||||
|
fp = append(fp, h+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Collapse the HTML error page to something short.
|
||||||
|
summary := stripHTMLBrief(body)
|
||||||
|
if summary == "" {
|
||||||
|
summary = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
out := fmt.Sprintf("HTTP %d — %s", resp.StatusCode, summary)
|
||||||
|
if server != "" {
|
||||||
|
out += " [server=" + server + "]"
|
||||||
|
}
|
||||||
|
if len(fp) > 0 {
|
||||||
|
out += " [" + strings.Join(fp, " ") + "]"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripHTMLBrief removes tags and collapses whitespace, returning the first ~160
|
||||||
|
// chars of visible text — enough to read "403 Forbidden" without the markup.
|
||||||
|
func stripHTMLBrief(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
depth := 0
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '<':
|
||||||
|
depth++
|
||||||
|
case '>':
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if depth == 0 {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := strings.Join(strings.Fields(b.String()), " ")
|
||||||
|
if len(out) > 160 {
|
||||||
|
out = out[:160] + "…"
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClublog validates the configured credentials by attempting a no-op
|
// TestClublog validates the configured credentials by attempting a no-op
|
||||||
@@ -164,6 +224,7 @@ func clublogPost(ctx context.Context, client *http.Client, endpoint string, form
|
|||||||
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("User-Agent", clublogUserAgent)
|
||||||
if client == nil {
|
if client == nil {
|
||||||
client = &http.Client{Timeout: 20 * time.Second}
|
client = &http.Client{Timeout: 20 * time.Second}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,155 @@ func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string)
|
|||||||
return msg, nil
|
return msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eQSL Inbox download endpoint + base host. DownloadInBox.cfm builds an ADIF of
|
||||||
|
// the account's *received* eQSLs (confirmations) and returns an HTML page that
|
||||||
|
// links to the generated .adi file; we then fetch that file (two-step API).
|
||||||
|
const (
|
||||||
|
eqslDownloadInboxURL = "https://www.eQSL.cc/qslcard/DownloadInBox.cfm"
|
||||||
|
eqslBaseURL = "https://www.eQSL.cc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eqslAdiHrefRe pulls the generated .adi link out of the DownloadInBox reply.
|
||||||
|
// eQSL serves old HTML 4.01 with UPPERCASE tags and often UNQUOTED attributes,
|
||||||
|
// e.g. `<A HREF=/downloadedFiles/xxxx.adi>` or `<a href="…/xxxx.adi">`, and the
|
||||||
|
// path may be root-relative or a bare filename. Match case-insensitively with
|
||||||
|
// optional quoting; resolve to an absolute URL afterwards.
|
||||||
|
var eqslAdiHrefRe = regexp.MustCompile(`(?i)href\s*=\s*["']?([^"'>\s]+\.adi)`)
|
||||||
|
|
||||||
|
// DownloadEQSLConfirmations fetches the account's Inbox (received eQSLs =
|
||||||
|
// confirmations) as ADIF text. since is "YYYY-MM-DD" (only QSLs received since
|
||||||
|
// then, incremental "last download"); qthNick comes from the config when the
|
||||||
|
// account has several QTH profiles.
|
||||||
|
func DownloadEQSLConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
|
||||||
|
user := strings.ToUpper(strings.TrimSpace(cfg.Username))
|
||||||
|
if user == "" || strings.TrimSpace(cfg.Password) == "" {
|
||||||
|
return "", fmt.Errorf("eqsl: username/password not set")
|
||||||
|
}
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 120 * time.Second}
|
||||||
|
}
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("UserName", user)
|
||||||
|
q.Set("Password", cfg.Password)
|
||||||
|
if nick := strings.TrimSpace(cfg.QTHNickname); nick != "" {
|
||||||
|
q.Set("QTHNickname", nick)
|
||||||
|
}
|
||||||
|
if s := eqslRcvdSince(since); s != "" {
|
||||||
|
q.Set("RcvdSince", s) // eQSL wants YYYYMMDDHHMM
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: ask eQSL to build the inbox ADIF; the reply is HTML linking to it.
|
||||||
|
page, err := eqslGet(ctx, client, eqslDownloadInboxURL+"?"+q.Encode())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if reason := authErrEQSL(page); reason != "" {
|
||||||
|
return "", fmt.Errorf("eqsl: %s", reason)
|
||||||
|
}
|
||||||
|
m := eqslAdiHrefRe.FindStringSubmatch(page)
|
||||||
|
if m == nil {
|
||||||
|
// eQSL reports problems inline ("Error: …", "You have no … confirmations").
|
||||||
|
return "", fmt.Errorf("eqsl: no download link in response: %s", eqslNoLinkReason(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the link to an absolute URL. eQSL is a Windows/ColdFusion server, so
|
||||||
|
// the path may use BACKSLASHES and a leading `..` (e.g. `..\downloadedFiles\
|
||||||
|
// xxx.adi`); normalise those. It may also be absolute or a bare filename.
|
||||||
|
link := strings.TrimSpace(m[1])
|
||||||
|
link = strings.ReplaceAll(link, `\`, "/")
|
||||||
|
link = strings.TrimPrefix(link, "..") // "../downloadedFiles/…" → "/downloadedFiles/…"
|
||||||
|
var fileURL string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(strings.ToLower(link), "http"):
|
||||||
|
fileURL = link
|
||||||
|
case strings.HasPrefix(link, "/"):
|
||||||
|
fileURL = eqslBaseURL + link
|
||||||
|
default:
|
||||||
|
fileURL = eqslBaseURL + "/" + link
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: fetch the generated .adi file.
|
||||||
|
adif, err := eqslGet(ctx, client, fileURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("eqsl: fetch adif: %w", err)
|
||||||
|
}
|
||||||
|
return adif, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// eqslNoLinkReason builds a helpful message when no .adi link was found: eQSL's
|
||||||
|
// own error/notice text if present, else a short tag-stripped snippet of the page
|
||||||
|
// so the real wording (e.g. "You have no Inbox items") is visible in the log.
|
||||||
|
func eqslNoLinkReason(page string) string {
|
||||||
|
low := strings.ToLower(page)
|
||||||
|
for _, marker := range []string{"you have no", "no inbox", "error", "invalid", "not found"} {
|
||||||
|
if i := strings.Index(low, marker); i >= 0 {
|
||||||
|
seg := page[i:]
|
||||||
|
if len(seg) > 160 {
|
||||||
|
seg = seg[:160]
|
||||||
|
}
|
||||||
|
return strings.Join(strings.Fields(stripTags(seg)), " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
txt := strings.Join(strings.Fields(stripTags(page)), " ")
|
||||||
|
if len(txt) > 200 {
|
||||||
|
txt = txt[:200]
|
||||||
|
}
|
||||||
|
if txt == "" {
|
||||||
|
txt = "empty response"
|
||||||
|
}
|
||||||
|
return txt
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripTags removes HTML tags for a readable one-line diagnostic.
|
||||||
|
func stripTags(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
depth := 0
|
||||||
|
for _, r := range s {
|
||||||
|
switch r {
|
||||||
|
case '<':
|
||||||
|
depth++
|
||||||
|
case '>':
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if depth == 0 {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// eqslGet does a plain GET and returns the body as text (32 MB cap), erroring on
|
||||||
|
// a non-200 status.
|
||||||
|
func eqslGet(ctx context.Context, client *http.Client, u string) (string, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("eqsl: build request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("eqsl: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("eqsl: http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// eqslRcvdSince converts the app's "YYYY-MM-DD" into eQSL's RcvdSince format
|
||||||
|
// "YYYYMMDDHHMM" (midnight). Empty in → empty out (full pull).
|
||||||
|
func eqslRcvdSince(since string) string {
|
||||||
|
s := strings.ReplaceAll(strings.TrimSpace(since), "-", "")
|
||||||
|
if len(s) == 8 { // YYYYMMDD → append 0000 (00:00)
|
||||||
|
return s + "0000"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// authErrEQSL returns a reason when the response signals bad credentials, else
|
// authErrEQSL returns a reason when the response signals bad credentials, else
|
||||||
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
|
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
|
||||||
func authErrEQSL(body string) string {
|
func authErrEQSL(body string) string {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package extsvc
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,6 +214,15 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri
|
|||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
if ee, ok := runErr.(*exec.ExitError); ok {
|
if ee, ok := runErr.(*exec.ExitError); ok {
|
||||||
code = ee.ExitCode()
|
code = ee.ExitCode()
|
||||||
|
} else if errors.Is(runErr, syscall.Errno(740)) || strings.Contains(strings.ToLower(runErr.Error()), "requires elevation") {
|
||||||
|
// ERROR_ELEVATION_REQUIRED (740): tqsl.exe is set to require admin
|
||||||
|
// rights (its "Run as administrator" compatibility flag, or an
|
||||||
|
// AppCompat RUNASADMIN entry), but OpsLog isn't elevated so Windows
|
||||||
|
// refuses to launch it. Actionable message instead of the raw error.
|
||||||
|
return UploadResult{}, fmt.Errorf(
|
||||||
|
"lotw: Windows won't launch tqsl.exe because it's marked \"Run as administrator\". " +
|
||||||
|
"Fix: right-click %q → Properties → Compatibility → UNTICK \"Run this program as an administrator\" (Apply). " +
|
||||||
|
"Or run OpsLog itself as administrator.", tqsl)
|
||||||
} else {
|
} else {
|
||||||
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
|
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,15 @@ type Event struct {
|
|||||||
|
|
||||||
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
|
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
|
||||||
DXGrid string // ServiceWSJT (Status)
|
DXGrid string // ServiceWSJT (Status)
|
||||||
Mode string // ServiceWSJT (Status)
|
Mode string // ServiceWSJT (Status/Decode)
|
||||||
FreqHz int64 // ServiceWSJT (Status)
|
FreqHz int64 // ServiceWSJT (Status)
|
||||||
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
|
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
|
||||||
|
|
||||||
|
// A WSJT-X Decode (heard station) to render on the panadapter.
|
||||||
|
DecodeCall string // transmitting (DE) callsign
|
||||||
|
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
||||||
|
DecodeSNR int // reported SNR (dB)
|
||||||
|
DecodeCQ bool // the decode was a CQ
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is a single inbound UDP listener.
|
// Server is a single inbound UDP listener.
|
||||||
@@ -57,6 +63,8 @@ type Server struct {
|
|||||||
done chan struct{}
|
done chan struct{}
|
||||||
stopped bool
|
stopped bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
|
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServer(cfg Config, out chan<- Event) *Server {
|
func newServer(cfg Config, out chan<- Event) *Server {
|
||||||
@@ -175,9 +183,29 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
applog.Printf("udp: [%s] WSJT msg type ignored\n", s.cfg.Name)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Status carries the current dial frequency; remember it so Decode audio
|
||||||
|
// offsets can be turned into RF frequencies for the panadapter.
|
||||||
|
if w.FreqHz > 0 && !w.IsDecode {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastDialHz = w.FreqHz
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
if w.IsDecode {
|
||||||
|
s.mu.Lock()
|
||||||
|
dial := s.lastDialHz
|
||||||
|
s.mu.Unlock()
|
||||||
|
if dial <= 0 {
|
||||||
|
return // no dial freq yet (Status not seen) → can't place the spot
|
||||||
|
}
|
||||||
|
ev.DecodeCall = w.DecodeCall
|
||||||
|
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
||||||
|
ev.DecodeSNR = w.SNR
|
||||||
|
ev.DecodeCQ = w.IsCQ
|
||||||
|
ev.Mode = w.Mode
|
||||||
|
break
|
||||||
|
}
|
||||||
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||||
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||||
ev.DXCall = w.DXCall
|
ev.DXCall = w.DXCall
|
||||||
@@ -241,7 +269,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Empty events are useless; skip.
|
// Empty events are useless; skip.
|
||||||
if ev.DXCall == "" && ev.LoggedADIF == "" {
|
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -37,18 +37,27 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// WSJTEvent is the parsed, typed result of decoding a single packet.
|
// WSJTEvent is the parsed, typed result of decoding a single packet.
|
||||||
// One of (DXCall, LoggedADIF) is non-empty depending on the message.
|
// One of (DXCall, LoggedADIF, DecodeCall) is non-empty depending on the message.
|
||||||
type WSJTEvent struct {
|
type WSJTEvent struct {
|
||||||
DXCall string // current "DX Call" field in the WSJT app
|
DXCall string // current "DX Call" field in the WSJT app (Status)
|
||||||
DXGrid string // optional grid for that call
|
DXGrid string // optional grid for that call (Status)
|
||||||
Mode string // FT8 / FT4 / …
|
Mode string // FT8 / FT4 / …
|
||||||
FreqHz int64 // current dial freq when available
|
FreqHz int64 // current dial freq when available (Status)
|
||||||
LoggedADIF string // full ADIF text when message is LoggedADIF
|
LoggedADIF string // full ADIF text when message is LoggedADIF
|
||||||
ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup
|
ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup
|
||||||
|
|
||||||
|
// Decode (type 2): the transmitting station heard on the band. FreqHz is NOT
|
||||||
|
// set here (Decode carries only the audio offset); the caller adds the last
|
||||||
|
// known dial frequency (from Status) to DeltaFreqHz to get the RF frequency.
|
||||||
|
IsDecode bool
|
||||||
|
DecodeCall string // the sender (DE) callsign extracted from the message text
|
||||||
|
DeltaFreqHz int64 // audio offset within the passband (Hz)
|
||||||
|
SNR int // reported signal-to-noise (dB)
|
||||||
|
IsCQ bool // the decode was a CQ call
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseWSJT decodes one UDP packet. Returns ok=false for messages we
|
// ParseWSJT decodes one UDP packet. Returns ok=false for messages we
|
||||||
// don't care about (heartbeat, decode lines, clears, etc.).
|
// don't care about (heartbeat, clears, etc.).
|
||||||
func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||||
if len(pkt) < 12 {
|
if len(pkt) < 12 {
|
||||||
return WSJTEvent{}, false, fmt.Errorf("packet too short")
|
return WSJTEvent{}, false, fmt.Errorf("packet too short")
|
||||||
@@ -143,6 +152,56 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
|||||||
ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))
|
ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))
|
||||||
return ev, true, nil
|
return ev, true, nil
|
||||||
|
|
||||||
|
case wsjtMsgDecode:
|
||||||
|
// Decode payload (v2):
|
||||||
|
// bool is_new
|
||||||
|
// quint32 time (ms since midnight)
|
||||||
|
// qint32 snr
|
||||||
|
// double delta_time (seconds)
|
||||||
|
// quint32 delta_frequency (Hz, audio offset in the passband)
|
||||||
|
// QUtf8 mode
|
||||||
|
// QUtf8 message (the decoded text, e.g. "CQ K1ABC FN42")
|
||||||
|
// bool low_confidence
|
||||||
|
// bool off_air
|
||||||
|
var b uint8
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &b); err != nil { // is_new
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
var t32, df uint32
|
||||||
|
var snr int32
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &t32); err != nil { // time
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &snr); err != nil {
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
var dt float64
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &dt); err != nil { // delta_time
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
if err := binary.Read(r, binary.BigEndian, &df); err != nil { // delta_frequency
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
mode, err := readQString(r)
|
||||||
|
if err != nil {
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
msg, err := readQString(r)
|
||||||
|
if err != nil {
|
||||||
|
return WSJTEvent{}, false, err
|
||||||
|
}
|
||||||
|
call, isCQ := wsjtSender(msg)
|
||||||
|
if call == "" {
|
||||||
|
return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore
|
||||||
|
}
|
||||||
|
ev.IsDecode = true
|
||||||
|
ev.DecodeCall = call
|
||||||
|
ev.IsCQ = isCQ
|
||||||
|
ev.DeltaFreqHz = int64(df)
|
||||||
|
ev.SNR = int(snr)
|
||||||
|
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
return ev, true, nil
|
||||||
|
|
||||||
case wsjtMsgLoggedADIF:
|
case wsjtMsgLoggedADIF:
|
||||||
// Payload: a single QString containing the ADIF record.
|
// Payload: a single QString containing the ADIF record.
|
||||||
adif, err := readQString(r)
|
adif, err := readQString(r)
|
||||||
@@ -155,6 +214,64 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
|||||||
return WSJTEvent{}, false, nil
|
return WSJTEvent{}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wsjtSender extracts the transmitting (DE) callsign from a WSJT-X message and
|
||||||
|
// whether it was a CQ. Grammar:
|
||||||
|
//
|
||||||
|
// CQ [modifier] <de_call> [grid] → de_call, isCQ=true
|
||||||
|
// <to_call> <de_call> [report|…] → de_call, isCQ=false
|
||||||
|
//
|
||||||
|
// Returns "" for free-text / telemetry / hashed-call messages we can't resolve.
|
||||||
|
func wsjtSender(message string) (call string, isCQ bool) {
|
||||||
|
f := strings.Fields(strings.ToUpper(strings.TrimSpace(message)))
|
||||||
|
if len(f) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if f[0] == "CQ" {
|
||||||
|
// Skip an optional modifier after CQ (DX / a region like NA / a zone like
|
||||||
|
// 020) — it never looks like a callsign (no letter+digit mix).
|
||||||
|
idx := 1
|
||||||
|
if len(f) > 2 && !looksLikeCall(f[1]) {
|
||||||
|
idx = 2
|
||||||
|
}
|
||||||
|
if idx < len(f) && looksLikeCall(f[idx]) {
|
||||||
|
return f[idx], true
|
||||||
|
}
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
// Standard exchange: the DE (sender) call is the second token.
|
||||||
|
if len(f) >= 2 && looksLikeCall(f[1]) {
|
||||||
|
return f[1], false
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeCall is a loose callsign test: 3–12 chars of A–Z/0–9//, with at least
|
||||||
|
// one letter AND one digit. Rejects the fixed exchange tokens (RR73/RRR/73) that
|
||||||
|
// would otherwise pass.
|
||||||
|
func looksLikeCall(s string) bool {
|
||||||
|
switch s {
|
||||||
|
case "RR73", "RRR", "73":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(s) < 3 || len(s) > 12 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hasL, hasD := false, false
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
switch {
|
||||||
|
case c >= 'A' && c <= 'Z':
|
||||||
|
hasL = true
|
||||||
|
case c >= '0' && c <= '9':
|
||||||
|
hasD = true
|
||||||
|
case c == '/':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasL && hasD
|
||||||
|
}
|
||||||
|
|
||||||
// readQString reads a Qt QString as written by QDataStream: an int32 byte
|
// readQString reads a Qt QString as written by QDataStream: an int32 byte
|
||||||
// length (or -1 for null) followed by the UTF-8 bytes.
|
// length (or -1 for null) followed by the UTF-8 bytes.
|
||||||
func readQString(r *bytes.Reader) (string, error) {
|
func readQString(r *bytes.Reader) (string, error) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestWSJTSender(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
msg string
|
||||||
|
wantCall string
|
||||||
|
wantCQ bool
|
||||||
|
}{
|
||||||
|
{"CQ K1ABC FN42", "K1ABC", true},
|
||||||
|
{"CQ DX W2XYZ EM12", "W2XYZ", true}, // modifier "DX" skipped
|
||||||
|
{"CQ NA VE3ABC FN03", "VE3ABC", true}, // region modifier skipped
|
||||||
|
{"CQ 020 JA1XYZ PM95", "JA1XYZ", true},// zone modifier skipped
|
||||||
|
{"W2XYZ K1ABC -10", "K1ABC", false}, // exchange → sender is 2nd call
|
||||||
|
{"W2XYZ K1ABC R-10", "K1ABC", false},
|
||||||
|
{"W2XYZ K1ABC RR73", "K1ABC", false},
|
||||||
|
{"F4BPO K1ABC/P 73", "K1ABC/P", false},// portable call kept
|
||||||
|
{"CQ F/DL1ABC JO31", "F/DL1ABC", true},// compound prefix
|
||||||
|
// Non-callsign / free text → no sender.
|
||||||
|
{"TNX 73 GL", "", false},
|
||||||
|
{"K1ABC RR73", "", false}, // only one call + a token → 2nd token not a call
|
||||||
|
{"", "", false},
|
||||||
|
{"CQ CQ CQ", "", true}, // CQ but no resolvable call
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
call, cq := wsjtSender(c.msg)
|
||||||
|
if call != c.wantCall || cq != c.wantCQ {
|
||||||
|
t.Errorf("wsjtSender(%q) = (%q,%v), want (%q,%v)", c.msg, call, cq, c.wantCall, c.wantCQ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Package lotwusers tracks which callsigns are LoTW users and when they last
|
||||||
|
// uploaded, from ARRL's public "lotw-user-activity.csv" feed. OpsLog shows a
|
||||||
|
// colour-coded badge next to the entered callsign (recent upload = likely to
|
||||||
|
// confirm on LoTW). The list is cached on disk so it survives restarts.
|
||||||
|
package lotwusers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// feedURL is ARRL's public LoTW last-upload activity list (CSV:
|
||||||
|
// "CALLSIGN,YYYY-MM-DD,HH:MM:SS", one line per registered LoTW callsign).
|
||||||
|
const feedURL = "https://lotw.arrl.org/lotw-user-activity.csv"
|
||||||
|
|
||||||
|
const cacheFile = "lotw-user-activity.csv"
|
||||||
|
|
||||||
|
// Info is a lookup result for one callsign.
|
||||||
|
type Info struct {
|
||||||
|
IsUser bool `json:"is_user"`
|
||||||
|
LastUpload string `json:"last_upload,omitempty"` // YYYY-MM-DD
|
||||||
|
DaysAgo int `json:"days_ago"` // days since last upload (-1 if unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager holds the parsed list + cache location.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
users map[string]time.Time // UPPER(callsign) → last-upload date (UTC)
|
||||||
|
updated time.Time // when the cache was last refreshed
|
||||||
|
dir string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager loads any on-disk cache and returns a ready manager.
|
||||||
|
func NewManager(dataDir string) *Manager {
|
||||||
|
m := &Manager{
|
||||||
|
users: map[string]time.Time{},
|
||||||
|
dir: dataDir,
|
||||||
|
client: &http.Client{Timeout: 90 * time.Second},
|
||||||
|
}
|
||||||
|
m.loadCache()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) path() string { return filepath.Join(m.dir, cacheFile) }
|
||||||
|
|
||||||
|
func (m *Manager) loadCache() {
|
||||||
|
data, err := os.ReadFile(m.path())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.parse(data)
|
||||||
|
if fi, e := os.Stat(m.path()); e == nil {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.updated = fi.ModTime()
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download fetches the latest list, caches it to disk and replaces the in-memory
|
||||||
|
// map. Returns the number of callsigns loaded.
|
||||||
|
func (m *Manager) Download(ctx context.Context) (int, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "OpsLog")
|
||||||
|
resp, err := m.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("lotwusers: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return 0, fmt.Errorf("lotwusers: http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024*1024))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("lotwusers: read: %w", err)
|
||||||
|
}
|
||||||
|
n := m.parse(body)
|
||||||
|
if n == 0 {
|
||||||
|
return 0, fmt.Errorf("lotwusers: feed parsed to 0 callsigns")
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(m.path(), body, 0o644) // best-effort cache
|
||||||
|
m.mu.Lock()
|
||||||
|
m.updated = time.Now()
|
||||||
|
m.mu.Unlock()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse loads the CSV bytes into the map and returns the count.
|
||||||
|
func (m *Manager) parse(data []byte) int {
|
||||||
|
users := make(map[string]time.Time, 1<<20)
|
||||||
|
sc := bufio.NewScanner(bytes.NewReader(data))
|
||||||
|
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.Split(line, ",")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
call := strings.ToUpper(strings.TrimSpace(parts[0]))
|
||||||
|
if call == "" || strings.EqualFold(call, "callsign") {
|
||||||
|
continue // header row
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", strings.TrimSpace(parts[1]))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
users[call] = t
|
||||||
|
}
|
||||||
|
if len(users) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.users = users
|
||||||
|
m.mu.Unlock()
|
||||||
|
return len(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup reports whether callsign is a LoTW user and how long ago it uploaded.
|
||||||
|
// Tries the exact call, then (for portable calls like "EA8/DL1ABC" or "F5ABC/P")
|
||||||
|
// the longest slash-separated segment — the base call LoTW is keyed on.
|
||||||
|
func (m *Manager) Lookup(callsign string) Info {
|
||||||
|
c := strings.ToUpper(strings.TrimSpace(callsign))
|
||||||
|
if c == "" {
|
||||||
|
return Info{DaysAgo: -1}
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
if len(m.users) == 0 {
|
||||||
|
return Info{DaysAgo: -1}
|
||||||
|
}
|
||||||
|
t, ok := m.users[c]
|
||||||
|
if !ok && strings.Contains(c, "/") {
|
||||||
|
base := ""
|
||||||
|
for _, seg := range strings.Split(c, "/") {
|
||||||
|
if len(seg) > len(base) {
|
||||||
|
base = seg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t, ok = m.users[base]
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return Info{DaysAgo: -1}
|
||||||
|
}
|
||||||
|
days := int(time.Since(t).Hours() / 24)
|
||||||
|
if days < 0 {
|
||||||
|
days = 0
|
||||||
|
}
|
||||||
|
return Info{IsUser: true, LastUpload: t.Format("2006-01-02"), DaysAgo: days}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns how many callsigns are loaded.
|
||||||
|
func (m *Manager) Count() int {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return len(m.users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updated returns when the list was last refreshed (zero if never).
|
||||||
|
func (m *Manager) Updated() time.Time {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.updated
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// Package offlineq is OpsLog's offline safety net.
|
||||||
|
//
|
||||||
|
// When the shared MySQL logbook is unreachable, a QSO must never be lost just
|
||||||
|
// because the network blinked. Instead of failing, the QSO is appended to a
|
||||||
|
// local ADIF file (the "outbox") and replayed into the database as soon as it
|
||||||
|
// comes back — then the file is ARCHIVED, never deleted.
|
||||||
|
//
|
||||||
|
// Deliberately NOT a sync engine: it only ever PUSHES the operator's own QSOs.
|
||||||
|
// There is no mirror, no pull, no merge, no tombstones — which is exactly why it
|
||||||
|
// stays small. The cost, accepted by design: during an outage you don't see other
|
||||||
|
// operators' QSOs and the worked-before check doesn't know about your pending
|
||||||
|
// ones. See the queue view in the UI for what's waiting.
|
||||||
|
//
|
||||||
|
// The file lives in OpsLog's data directory — NEVER in a cloud-synced folder
|
||||||
|
// (Seafile/OneDrive): replicating a live file byte-by-byte is what we're
|
||||||
|
// escaping in the first place.
|
||||||
|
package offlineq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/adif"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileName is the outbox. Pending QSOs accumulate here while the DB is down.
|
||||||
|
const FileName = "opslog-pending.adi"
|
||||||
|
|
||||||
|
// Queue owns the outbox file. All operations are serialised: the logging path
|
||||||
|
// (append) and the replay loop (read/rewrite/archive) run on different
|
||||||
|
// goroutines.
|
||||||
|
type Queue struct {
|
||||||
|
dir string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a queue backed by <dir>/opslog-pending.adi.
|
||||||
|
func New(dir string) *Queue { return &Queue{dir: strings.TrimSpace(dir)} }
|
||||||
|
|
||||||
|
// Path is the outbox file's location (shown in the UI so the operator always
|
||||||
|
// knows where their QSOs physically are).
|
||||||
|
func (q *Queue) Path() string { return filepath.Join(q.dir, FileName) }
|
||||||
|
|
||||||
|
// newQueueID mints the id that makes replay idempotent.
|
||||||
|
func newQueueID() string {
|
||||||
|
var b [12]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append parks a QSO in the outbox, stamping it with a queue id (returned) so a
|
||||||
|
// repeated replay can recognise it. The file is created with an ADIF header on
|
||||||
|
// first use, then appended to — an append can't corrupt what's already there.
|
||||||
|
func (q *Queue) Append(rec qso.QSO) (string, error) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
if rec.Extras == nil {
|
||||||
|
rec.Extras = map[string]string{}
|
||||||
|
}
|
||||||
|
qid := strings.TrimSpace(rec.Extras[qso.OfflineQueueKey])
|
||||||
|
if qid == "" {
|
||||||
|
qid = newQueueID()
|
||||||
|
rec.Extras[qso.OfflineQueueKey] = qid
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(q.dir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: create dir: %w", err)
|
||||||
|
}
|
||||||
|
path := q.Path()
|
||||||
|
_, statErr := os.Stat(path)
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
if os.IsNotExist(statErr) { // brand-new file → write the ADIF header once
|
||||||
|
b.WriteString("OpsLog offline queue — QSOs logged while the database was unreachable.\n")
|
||||||
|
b.WriteString("<ADIF_VER:5>3.1.4 <PROGRAMID:6>OpsLog <EOH>\n\n")
|
||||||
|
}
|
||||||
|
b.WriteString(strings.TrimRight(adif.FullRecordADIF(rec), "\r\n"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
if _, err := f.WriteString(b.String()); err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: write: %w", err)
|
||||||
|
}
|
||||||
|
// Flush to disk: the whole point is surviving a crash/power cut.
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: sync: %w", err)
|
||||||
|
}
|
||||||
|
return qid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending parses the outbox into QSOs (each carrying its queue id in Extras).
|
||||||
|
// A missing file simply means nothing is pending.
|
||||||
|
func (q *Queue) Pending() ([]qso.QSO, error) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
return q.pendingLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) pendingLocked() ([]qso.QSO, error) {
|
||||||
|
f, err := os.Open(q.Path())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var out []qso.QSO
|
||||||
|
err = adif.Parse(f, func(rec adif.Record) error {
|
||||||
|
if v, ok := adif.RecordToQSO(rec); ok {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return out, fmt.Errorf("offlineq: parse %s: %w", q.Path(), err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count is how many QSOs are waiting (0 when the file is absent).
|
||||||
|
func (q *Queue) Count() int {
|
||||||
|
p, err := q.Pending()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite replaces the outbox with exactly these QSOs — used after a replay to
|
||||||
|
// keep only the ones that FAILED. Written to a temp file and renamed, so a crash
|
||||||
|
// mid-write can't truncate the queue.
|
||||||
|
func (q *Queue) Rewrite(keep []qso.QSO) error {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
return q.rewriteLocked(keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) rewriteLocked(keep []qso.QSO) error {
|
||||||
|
path := q.Path()
|
||||||
|
if len(keep) == 0 {
|
||||||
|
// Nothing left: remove the (now empty) outbox. The caller archives the
|
||||||
|
// original content first, so this never destroys the only copy.
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("offlineq: remove: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("OpsLog offline queue — QSOs logged while the database was unreachable.\n")
|
||||||
|
b.WriteString("<ADIF_VER:5>3.1.4 <PROGRAMID:6>OpsLog <EOH>\n\n")
|
||||||
|
for _, v := range keep {
|
||||||
|
b.WriteString(strings.TrimRight(adif.FullRecordADIF(v), "\r\n"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("offlineq: write temp: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
return fmt.Errorf("offlineq: replace: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive copies the outbox to a timestamped file BEFORE it is cleared, so the
|
||||||
|
// QSOs always exist somewhere on disk even if the replay later turns out to have
|
||||||
|
// gone wrong. Deleting the only copy of someone's contacts is the one mistake
|
||||||
|
// you don't get to undo.
|
||||||
|
func (q *Queue) Archive() (string, error) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(q.Path())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("opslog-pending-%s.adi", time.Now().Format("2006-01-02-1504"))
|
||||||
|
dst := filepath.Join(q.dir, name)
|
||||||
|
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("offlineq: archive: %w", err)
|
||||||
|
}
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package offlineq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The outbox is the ONLY copy of a QSO logged while the database was down, so the
|
||||||
|
// round-trip must be lossless: append → read back identical (callsign, band, mode,
|
||||||
|
// date) and each record must carry a queue id (what makes the replay idempotent).
|
||||||
|
func TestQueueRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
q := New(dir)
|
||||||
|
|
||||||
|
if n := q.Count(); n != 0 {
|
||||||
|
t.Fatalf("fresh queue: count = %d, want 0", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
mk := func(call, band, mode string) qso.QSO {
|
||||||
|
return qso.QSO{
|
||||||
|
Callsign: call, Band: band, Mode: mode,
|
||||||
|
QSODate: time.Date(2026, 7, 10, 12, 34, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id1, err := q.Append(mk("K1ABC", "20m", "SSB"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("append 1: %v", err)
|
||||||
|
}
|
||||||
|
id2, err := q.Append(mk("DL1XYZ", "40m", "CW"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("append 2: %v", err)
|
||||||
|
}
|
||||||
|
if id1 == "" || id2 == "" || id1 == id2 {
|
||||||
|
t.Fatalf("queue ids must be non-empty and distinct: %q / %q", id1, id2)
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := q.Pending()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pending: %v", err)
|
||||||
|
}
|
||||||
|
if len(pending) != 2 {
|
||||||
|
t.Fatalf("pending = %d, want 2", len(pending))
|
||||||
|
}
|
||||||
|
if pending[0].Callsign != "K1ABC" || pending[0].Band != "20m" || pending[0].Mode != "SSB" {
|
||||||
|
t.Errorf("record 1 round-tripped wrong: %+v", pending[0])
|
||||||
|
}
|
||||||
|
if pending[1].Callsign != "DL1XYZ" || pending[1].Mode != "CW" {
|
||||||
|
t.Errorf("record 2 round-tripped wrong: %+v", pending[1])
|
||||||
|
}
|
||||||
|
// The queue id must survive the ADIF round-trip — without it the replay
|
||||||
|
// can't be idempotent and a crash would duplicate contacts.
|
||||||
|
for i, p := range pending {
|
||||||
|
if p.Extras[qso.OfflineQueueKey] == "" {
|
||||||
|
t.Errorf("record %d lost its %s extra", i, qso.OfflineQueueKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive keeps a copy BEFORE we clear anything.
|
||||||
|
arch, err := q.Archive()
|
||||||
|
if err != nil || arch == "" {
|
||||||
|
t.Fatalf("archive: %v (path %q)", err, arch)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(arch); err != nil {
|
||||||
|
t.Fatalf("archive file missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partial replay: the first QSO went in, the second failed → keep only it.
|
||||||
|
if err := q.Rewrite(pending[1:]); err != nil {
|
||||||
|
t.Fatalf("rewrite: %v", err)
|
||||||
|
}
|
||||||
|
left, err := q.Pending()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pending after rewrite: %v", err)
|
||||||
|
}
|
||||||
|
if len(left) != 1 || left[0].Callsign != "DL1XYZ" {
|
||||||
|
t.Fatalf("after rewrite: got %d records (%v), want just DL1XYZ", len(left), left)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything replayed → the outbox is removed (the archive still holds it).
|
||||||
|
if err := q.Rewrite(nil); err != nil {
|
||||||
|
t.Fatalf("rewrite empty: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, FileName)); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("outbox should be gone once fully replayed, stat err = %v", err)
|
||||||
|
}
|
||||||
|
if n := q.Count(); n != 0 {
|
||||||
|
t.Errorf("count after full replay = %d, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tm(s string) time.Time {
|
||||||
|
t, _ := time.Parse("2006-01-02T15:04", s)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchIndexWindowAndMode(t *testing.T) {
|
||||||
|
idx := &MatchIndex{byMode: map[string][]matchRef{}}
|
||||||
|
// Local log: a CW QSO, an SSB (logged as USB) QSO, and an FT8 QSO.
|
||||||
|
idx.Add("4Z4DX", "15m", "CW", tm("2026-06-20T10:13"), 1)
|
||||||
|
idx.Add("V85NPV", "15m", "USB", tm("2025-04-18T17:01"), 2)
|
||||||
|
idx.Add("DK0SWL", "20m", "FT8", tm("2026-03-01T09:00"), 3)
|
||||||
|
|
||||||
|
win := 10 * time.Minute
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
call, band, mode string
|
||||||
|
when string
|
||||||
|
wantID int64
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"exact", "4Z4DX", "15m", "CW", "2026-06-20T10:13", 1, true},
|
||||||
|
{"1-min drift", "4Z4DX", "15m", "CW", "2026-06-20T10:12", 1, true},
|
||||||
|
{"9-min drift ok", "4Z4DX", "15m", "CW", "2026-06-20T10:04", 1, true},
|
||||||
|
{"30-min drift misses", "4Z4DX", "15m", "CW", "2026-06-20T10:43", 0, false},
|
||||||
|
// Phone sidebands are interchangeable: an SSB confirmation matches a USB log.
|
||||||
|
{"SSB conf vs USB log", "V85NPV", "15m", "SSB", "2025-04-18T17:00", 2, true},
|
||||||
|
{"LSB conf vs USB log", "V85NPV", "15m", "LSB", "2025-04-18T17:00", 2, true},
|
||||||
|
// But a digital/phone mismatch does NOT match (exact for non-phone).
|
||||||
|
{"FT8 conf vs SSB log misses", "V85NPV", "15m", "FT8", "2025-04-18T17:00", 0, false},
|
||||||
|
// Digital is exact: FT8 matches FT8, but FT4 would not.
|
||||||
|
{"FT8 exact", "DK0SWL", "20m", "FT8", "2026-03-01T09:01", 3, true},
|
||||||
|
{"FT4 vs FT8 misses", "DK0SWL", "20m", "FT4", "2026-03-01T09:01", 0, false},
|
||||||
|
{"wrong band misses", "DK0SWL", "40m", "FT8", "2026-03-01T09:01", 0, false},
|
||||||
|
{"unknown call misses", "N0CALL", "15m", "CW", "2026-06-20T10:13", 0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
id, ok := idx.Match(c.call, c.band, c.mode, tm(c.when), win)
|
||||||
|
if ok != c.wantOK || (ok && id != c.wantID) {
|
||||||
|
t.Errorf("%s: got (id=%d ok=%v), want (id=%d ok=%v)", c.name, id, ok, c.wantID, c.wantOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -490,6 +490,7 @@ type UploadRow struct {
|
|||||||
// Manager may filter on (guards the dynamic column name in the query).
|
// Manager may filter on (guards the dynamic column name in the query).
|
||||||
var uploadStatusCols = map[string]bool{
|
var uploadStatusCols = map[string]bool{
|
||||||
"lotw_sent": true,
|
"lotw_sent": true,
|
||||||
|
"eqsl_sent": true,
|
||||||
"qrzcom_qso_upload_status": true,
|
"qrzcom_qso_upload_status": true,
|
||||||
"clublog_qso_upload_status": true,
|
"clublog_qso_upload_status": true,
|
||||||
"hrdlog_qso_upload_status": true,
|
"hrdlog_qso_upload_status": true,
|
||||||
@@ -1010,6 +1011,32 @@ func FilterableFields() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// columnExpr resolves a filter field to a safe SQL expression — either a
|
// columnExpr resolves a filter field to a safe SQL expression — either a
|
||||||
|
// OfflineQueueKey is the ADIF extras key stamped on a QSO that was parked in the
|
||||||
|
// offline safety file. It survives the ADIF round-trip and makes the replay
|
||||||
|
// IDEMPOTENT: if the app dies between "inserted into the DB" and "removed from
|
||||||
|
// the file", the next replay sees the id already in the log and skips it instead
|
||||||
|
// of creating a duplicate.
|
||||||
|
const OfflineQueueKey = "APP_OPSLOG_QUEUEID"
|
||||||
|
|
||||||
|
// ExistsByQueueID reports whether a QSO carrying this offline-queue id is already
|
||||||
|
// in the logbook — the guard that makes replaying the safety file safe to repeat.
|
||||||
|
func (r *Repo) ExistsByQueueID(ctx context.Context, qid string) (bool, error) {
|
||||||
|
qid = strings.TrimSpace(qid)
|
||||||
|
if qid == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
expr := "json_extract(extras_json, '$." + OfflineQueueKey + "')"
|
||||||
|
if db.IsMySQL() {
|
||||||
|
expr = "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(extras_json,''), '$." + OfflineQueueKey + "'))"
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := r.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM qso WHERE `+expr+` = ?`, qid).Scan(&n); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// whitelisted column name or a json_extract over extras_json.
|
// whitelisted column name or a json_extract over extras_json.
|
||||||
func columnExpr(field string) (string, bool) {
|
func columnExpr(field string) (string, bool) {
|
||||||
f := strings.ToLower(strings.TrimSpace(field))
|
f := strings.ToLower(strings.TrimSpace(field))
|
||||||
@@ -1813,6 +1840,117 @@ func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matchRef is one local QSO's time + id, for time-window confirmation matching.
|
||||||
|
type matchRef struct {
|
||||||
|
when time.Time
|
||||||
|
id int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchIndex matches a downloaded confirmation to a local QSO within a TIME
|
||||||
|
// WINDOW (not an exact minute): QSL confirmations carry the OTHER station's
|
||||||
|
// logged time, which drifts a minute or two from ours. The MODE must match, with
|
||||||
|
// ONE equivalence: the phone modes SSB/USB/LSB are interchangeable (a station may
|
||||||
|
// confirm USB where we logged SSB). Every other mode is exact (FT8 only matches
|
||||||
|
// FT8, FT4 only FT4, CW only CW…). Built in one table scan.
|
||||||
|
type MatchIndex struct {
|
||||||
|
byMode map[string][]matchRef // call|band|canonMode → refs
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonMode folds the phone sidebands into a single "SSB" bucket; every other
|
||||||
|
// mode is returned uppercased and unchanged (exact-match).
|
||||||
|
func canonMode(mode string) string {
|
||||||
|
m := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
switch m {
|
||||||
|
case "SSB", "USB", "LSB":
|
||||||
|
return "SSB"
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchKeyMode(call, band, mode string) string {
|
||||||
|
return strings.ToUpper(call) + "|" + strings.ToLower(band) + "|" + canonMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseQSODate reads the stored qso_date (ISO "2006-01-02T15:04[:05…]") to minute
|
||||||
|
// precision as UTC. Zero time on failure (never matched).
|
||||||
|
func parseQSODate(s string) time.Time {
|
||||||
|
if len(s) >= 16 {
|
||||||
|
if t, err := time.Parse("2006-01-02T15:04", s[:16]); err == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildMatchIndex scans the log once and indexes every QSO by call+band(+mode)
|
||||||
|
// with its time, for windowed confirmation matching. When ownerCall is non-empty
|
||||||
|
// the index is SCOPED to QSOs whose station_callsign is that call (or blank —
|
||||||
|
// legacy QSOs that predate station-callsign stamping), so a confirmation download
|
||||||
|
// for one of the operator's calls (e.g. F4BPO) never touches QSOs logged under
|
||||||
|
// another (e.g. TM2Q).
|
||||||
|
func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchIndex, error) {
|
||||||
|
idx := &MatchIndex{byMode: map[string][]matchRef{}}
|
||||||
|
query := `SELECT id, callsign, qso_date, band, mode FROM qso`
|
||||||
|
var args []any
|
||||||
|
if oc := strings.ToUpper(strings.TrimSpace(ownerCall)); oc != "" {
|
||||||
|
query += ` WHERE UPPER(TRIM(COALESCE(station_callsign,''))) IN ('', ?)`
|
||||||
|
args = append(args, oc)
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return idx, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var call, when, band, mode string
|
||||||
|
if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil {
|
||||||
|
return idx, err
|
||||||
|
}
|
||||||
|
idx.add(call, band, mode, parseQSODate(when), id)
|
||||||
|
}
|
||||||
|
return idx, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// add inserts a QSO into the index (also used to register a freshly-inserted
|
||||||
|
// "add not-found" QSO so later confirmations in the same batch can match it).
|
||||||
|
func (idx *MatchIndex) add(call, band, mode string, when time.Time, id int64) {
|
||||||
|
mk := matchKeyMode(call, band, mode)
|
||||||
|
idx.byMode[mk] = append(idx.byMode[mk], matchRef{when: when, id: id})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add registers a QSO in the index (exported wrapper for callers that inserted a
|
||||||
|
// not-found confirmation).
|
||||||
|
func (idx *MatchIndex) Add(call, band, mode string, when time.Time, id int64) {
|
||||||
|
idx.add(call, band, mode, when, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match returns the id of the local QSO closest in time to `when` (within
|
||||||
|
// `window`) for the given call/band/mode. The mode must match (phone sidebands
|
||||||
|
// folded together by canonMode). Ok is false when nothing is within the window.
|
||||||
|
func (idx *MatchIndex) Match(call, band, mode string, when time.Time, window time.Duration) (int64, bool) {
|
||||||
|
return closestRef(idx.byMode[matchKeyMode(call, band, mode)], when, window)
|
||||||
|
}
|
||||||
|
|
||||||
|
func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, bool) {
|
||||||
|
var best int64
|
||||||
|
bestD := window + time.Second
|
||||||
|
found := false
|
||||||
|
for _, rf := range refs {
|
||||||
|
if rf.when.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d := when.Sub(rf.when)
|
||||||
|
if d < 0 {
|
||||||
|
d = -d
|
||||||
|
}
|
||||||
|
if d <= window && d < bestD {
|
||||||
|
bestD, best, found = d, rf.id, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, found
|
||||||
|
}
|
||||||
|
|
||||||
// ConfirmedSets captures which DXCC / band / slot combinations are already
|
// 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.
|
||||||
@@ -1890,6 +2028,19 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after
|
||||||
|
// an eQSL Inbox download. date is an ADIF YYYYMMDD string.
|
||||||
|
func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET eqsl_rcvd = 'Y', eqsl_rcvd_date = ?,
|
||||||
|
updated_at = ? WHERE id = ?`,
|
||||||
|
date, db.NowISO(), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark eqsl confirmed %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO
|
// MarkLoTWConfirmed stamps LOTW_QSL_RCVD=Y and the received date on a QSO
|
||||||
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
|
// after a LoTW confirmation download. date is an ADIF YYYYMMDD string.
|
||||||
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkLoTWConfirmed(ctx context.Context, id int64, date string) error {
|
||||||
|
|||||||
@@ -0,0 +1,667 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Statistics over the whole logbook.
|
||||||
|
//
|
||||||
|
// Everything is aggregated IN GO from one lean scan rather than with SQL GROUP
|
||||||
|
// BYs. Two reasons: the date maths (year / month / hour of day) would need
|
||||||
|
// dialect-specific functions — strftime() on SQLite vs YEAR()/HOUR() on MySQL —
|
||||||
|
// which is exactly the kind of thing that silently works on one backend and
|
||||||
|
// breaks on the other; and a single pass over a few columns of a 30k-row log is
|
||||||
|
// a few tens of milliseconds, so the complexity buys nothing.
|
||||||
|
|
||||||
|
// Bucket is one labelled count (mode, band, operator, entity…).
|
||||||
|
type Bucket struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
|
||||||
|
// are the expensive minutes: they are where the score went.
|
||||||
|
type Gap struct {
|
||||||
|
Start string `json:"start"` // RFC3339 — the last QSO before the silence
|
||||||
|
End string `json:"end"` // the first QSO after it
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContestRun is one contest the operator actually took part in, discovered FROM
|
||||||
|
// THE LOG (a CONTEST_ID plus the year it ran) rather than from a static list — so
|
||||||
|
// the picker only ever offers contests you really entered, and never an empty one.
|
||||||
|
type ContestRun struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Year int `json:"year"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Start string `json:"start"` // first QSO, RFC3339
|
||||||
|
End string `json:"end"` // last QSO
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContestRuns lists every (contest, year) pair present in the logbook, most
|
||||||
|
// recent first.
|
||||||
|
func (r *Repo) ContestRuns(ctx context.Context) ([]ContestRun, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT contest_id, qso_date FROM qso WHERE contest_id IS NOT NULL AND contest_id <> ''`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type key struct {
|
||||||
|
id string
|
||||||
|
year int
|
||||||
|
}
|
||||||
|
agg := map[key]*ContestRun{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, dateStr sql.NullString
|
||||||
|
if err := rows.Scan(&id, &dateStr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cid := strings.ToUpper(strings.TrimSpace(id.String))
|
||||||
|
if cid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := parseTimeLoose(dateStr.String).UTC()
|
||||||
|
if t.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := key{cid, t.Year()}
|
||||||
|
c, ok := agg[k]
|
||||||
|
if !ok {
|
||||||
|
c = &ContestRun{ID: cid, Year: t.Year(), Start: t.Format(time.RFC3339), End: t.Format(time.RFC3339)}
|
||||||
|
agg[k] = c
|
||||||
|
}
|
||||||
|
c.Count++
|
||||||
|
if t.Format(time.RFC3339) < c.Start {
|
||||||
|
c.Start = t.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if t.Format(time.RFC3339) > c.End {
|
||||||
|
c.End = t.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]ContestRun, 0, len(agg))
|
||||||
|
for _, c := range agg {
|
||||||
|
out = append(out, *c)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Year != out[j].Year {
|
||||||
|
return out[i].Year > out[j].Year // most recent first
|
||||||
|
}
|
||||||
|
return out[i].ID < out[j].ID
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gapThreshold is the silence that counts as "off the air". Short enough to catch
|
||||||
|
// a real break, long enough not to flag the normal pause between two QSOs.
|
||||||
|
const gapThreshold = 30 * time.Minute
|
||||||
|
|
||||||
|
// rateMaxHours caps the per-hour rate timeline. A contest weekend is ~48 h, so a
|
||||||
|
// week is generous. This is a READABILITY limit, not a memory one: at 30 days the
|
||||||
|
// chart is 720 hourly bars, each about a pixel wide with an unreadable label — it
|
||||||
|
// looks broken, which is exactly how it first shipped. Past this the UI says
|
||||||
|
// "period too long for an hourly chart" instead of drawing mush.
|
||||||
|
const rateMaxHours = 7 * 24
|
||||||
|
|
||||||
|
// Stats is the whole dashboard payload.
|
||||||
|
type Stats struct {
|
||||||
|
// Headline figures.
|
||||||
|
Total int `json:"total"`
|
||||||
|
UniqueCalls int `json:"unique_calls"`
|
||||||
|
Entities int `json:"entities"` // distinct DXCC entities
|
||||||
|
Continents int `json:"continents"` // distinct continents
|
||||||
|
FirstQSO string `json:"first_qso"` // RFC3339, "" when the log is empty
|
||||||
|
LastQSO string `json:"last_qso"`
|
||||||
|
|
||||||
|
// Confirmations (of Total).
|
||||||
|
ConfirmedLoTW int `json:"confirmed_lotw"`
|
||||||
|
ConfirmedEQSL int `json:"confirmed_eqsl"`
|
||||||
|
ConfirmedQSL int `json:"confirmed_qsl"`
|
||||||
|
ConfirmedAny int `json:"confirmed_any"`
|
||||||
|
|
||||||
|
// Breakdowns, each sorted most → least (bands keep frequency order).
|
||||||
|
ByMode []Bucket `json:"by_mode"`
|
||||||
|
ByBand []Bucket `json:"by_band"`
|
||||||
|
ByOperator []Bucket `json:"by_operator"`
|
||||||
|
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
||||||
|
ByContinent []Bucket `json:"by_continent"`
|
||||||
|
TopEntities []Bucket `json:"top_entities"`
|
||||||
|
ByYear []Bucket `json:"by_year"` // chronological
|
||||||
|
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
|
||||||
|
|
||||||
|
// ── Period / contest metrics ──
|
||||||
|
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
|
||||||
|
// nothing, but across a contest weekend it is the score. The window is the
|
||||||
|
// requested [from,to] when given, else the span of the log.
|
||||||
|
WindowStart string `json:"window_start"`
|
||||||
|
WindowEnd string `json:"window_end"`
|
||||||
|
WindowHours float64 `json:"window_hours"`
|
||||||
|
AvgPerHour float64 `json:"avg_per_hour"` // QSOs ÷ window hours (breaks included — the honest rate)
|
||||||
|
AvgPerActive float64 `json:"avg_per_active"` // QSOs ÷ ON-AIR hours
|
||||||
|
|
||||||
|
// On-air and off-air are a TIME BUDGET and must add up to the window:
|
||||||
|
// OnAirMinutes + OffAirMinutes == window
|
||||||
|
// The first version counted "clock hours containing at least one QSO" as on-air,
|
||||||
|
// so a single QSO at 08:05 booked the whole 08:00 hour. On a 45 h contest that
|
||||||
|
// gave 39 h on air AND 16 h 43 off air — 56 h inside a 45 h window. Two numbers
|
||||||
|
// measured on incompatible bases can't be compared, and the operator rightly
|
||||||
|
// didn't believe either of them.
|
||||||
|
OnAirMinutes int `json:"on_air_minutes"`
|
||||||
|
OffAirMinutes int `json:"off_air_minutes"`
|
||||||
|
|
||||||
|
PeakHourKey string `json:"peak_hour_key"` // best clock hour (kept for reference)
|
||||||
|
PeakHourCount int `json:"peak_hour_count"`
|
||||||
|
Best60 int `json:"best_60"` // best ROLLING 60 min — the number contesters quote
|
||||||
|
Gaps []Gap `json:"gaps"` // the silences that make up OffAirMinutes, longest first
|
||||||
|
Rate []Bucket `json:"rate"` // QSO per clock hour across the window ("MM-DD HH")
|
||||||
|
|
||||||
|
// The contest RATE SHEET: hour by hour, who made the QSOs.
|
||||||
|
// RateOps are the operators, busiest first — that fixed order is also the
|
||||||
|
// colour/legend order, so an operator keeps their hue across the whole page.
|
||||||
|
// RateByOp[h][o] is operator o's count in hour h; rows align 1:1 with Rate, so
|
||||||
|
// the per-operator numbers always sum to the hour's total.
|
||||||
|
RateOps []string `json:"rate_ops"`
|
||||||
|
RateByOp [][]int `json:"rate_by_op"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// entry is one dated QSO with the operator who made it — the pair the contest
|
||||||
|
// rate sheet needs. A bare timestamp can tell you HOW MANY, never BY WHOM.
|
||||||
|
type entry struct {
|
||||||
|
t time.Time
|
||||||
|
op string
|
||||||
|
}
|
||||||
|
|
||||||
|
// bandOrder sorts bands by frequency (160m → 70cm) rather than alphabetically,
|
||||||
|
// so the band chart reads like a band plan instead of a jumble.
|
||||||
|
var bandOrder = map[string]int{
|
||||||
|
"2190m": 1, "630m": 2, "160m": 3, "80m": 4, "60m": 5, "40m": 6, "30m": 7,
|
||||||
|
"20m": 8, "17m": 9, "15m": 10, "12m": 11, "10m": 12, "6m": 13, "4m": 14,
|
||||||
|
"2m": 15, "1.25m": 16, "70cm": 17, "23cm": 18, "13cm": 19,
|
||||||
|
}
|
||||||
|
|
||||||
|
// yes reports whether an ADIF confirmation flag means "confirmed".
|
||||||
|
func yes(s string) bool {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(s)) {
|
||||||
|
case "Y", "V": // V = verified (LoTW)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats scans the logbook once and returns every breakdown the dashboard needs,
|
||||||
|
// restricted to [from, to] (a zero time means "no bound", so a zero/zero pair is
|
||||||
|
// the whole log).
|
||||||
|
//
|
||||||
|
// The window is applied HERE, in Go, on the parsed timestamp — not as a SQL
|
||||||
|
// WHERE. qso_date is a text column whose format differs between the two backends,
|
||||||
|
// so a string comparison would quietly select the wrong rows on one of them. We
|
||||||
|
// already parse every date in this pass; filtering on the parsed value is both
|
||||||
|
// correct and free.
|
||||||
|
// contestID (with an optional year, 0 = any) narrows the log to one contest. When
|
||||||
|
// it is set and no explicit window is given, the window becomes the contest's own
|
||||||
|
// span — so rate, best-hour and off-air figures are computed over the contest
|
||||||
|
// itself without the operator having to look its dates up.
|
||||||
|
func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string, year int) (Stats, error) {
|
||||||
|
var s Stats
|
||||||
|
contestID = strings.ToUpper(strings.TrimSpace(contestID))
|
||||||
|
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT callsign, qso_date, band, mode, cont, country, dxcc,
|
||||||
|
operator, station_callsign, lotw_rcvd, eqsl_rcvd, qsl_rcvd, contest_id
|
||||||
|
FROM qso`)
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var (
|
||||||
|
calls = map[string]struct{}{}
|
||||||
|
entities = map[int]struct{}{}
|
||||||
|
modeC = map[string]int{}
|
||||||
|
bandC = map[string]int{}
|
||||||
|
opC = map[string]int{}
|
||||||
|
stationC = map[string]int{}
|
||||||
|
contC = map[string]int{}
|
||||||
|
entityC = map[string]int{}
|
||||||
|
yearC = map[string]int{}
|
||||||
|
monthC = map[string]int{}
|
||||||
|
times []entry // every dated QSO (+ its operator), for the rate / gap maths
|
||||||
|
first, last time.Time
|
||||||
|
)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
call, band, mode, cont, country sql.NullString
|
||||||
|
oper, station sql.NullString
|
||||||
|
lotw, eqsl, paper sql.NullString
|
||||||
|
dxcc sql.NullInt64
|
||||||
|
dateStr, contestID2 sql.NullString
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&call, &dateStr, &band, &mode, &cont, &country, &dxcc,
|
||||||
|
&oper, &station, &lotw, &eqsl, &paper, &contestID2); err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contest filter first — same reasoning as the window below: a QSO that
|
||||||
|
// isn't in this contest must not reach ANY bucket.
|
||||||
|
if contestID != "" && strings.ToUpper(strings.TrimSpace(contestID2.String)) != contestID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window first: a QSO outside the period must not reach ANY bucket. Doing
|
||||||
|
// this after the counting (the obvious mistake) would leave the mode/band/
|
||||||
|
// operator charts showing the whole log while only the trend was filtered.
|
||||||
|
// parseTimeLoose is the repo's existing convention for qso_date — it copes
|
||||||
|
// with what each backend hands back (SQLite ISO string, MySQL DATETIME).
|
||||||
|
t := parseTimeLoose(dateStr.String).UTC()
|
||||||
|
dated := !t.IsZero()
|
||||||
|
if year > 0 && (!dated || t.Year() != year) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !from.IsZero() && (!dated || t.Before(from)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !to.IsZero() && (!dated || t.After(to)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Total++
|
||||||
|
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(call.String)); c != "" {
|
||||||
|
calls[c] = struct{}{}
|
||||||
|
}
|
||||||
|
if dxcc.Valid && dxcc.Int64 > 0 {
|
||||||
|
entities[int(dxcc.Int64)] = struct{}{}
|
||||||
|
}
|
||||||
|
if m := strings.ToUpper(strings.TrimSpace(mode.String)); m != "" {
|
||||||
|
modeC[m]++
|
||||||
|
}
|
||||||
|
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
||||||
|
bandC[b]++
|
||||||
|
}
|
||||||
|
// An empty OPERATOR means "the station owner logged it himself" — bucket
|
||||||
|
// it explicitly rather than dropping the QSO from the operator chart.
|
||||||
|
op := strings.ToUpper(strings.TrimSpace(oper.String))
|
||||||
|
if op == "" {
|
||||||
|
op = "—"
|
||||||
|
}
|
||||||
|
opC[op]++
|
||||||
|
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
||||||
|
stationC[st]++
|
||||||
|
}
|
||||||
|
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
|
||||||
|
contC[c]++
|
||||||
|
}
|
||||||
|
if c := strings.TrimSpace(country.String); c != "" {
|
||||||
|
entityC[c]++
|
||||||
|
}
|
||||||
|
|
||||||
|
cl, el, pl := yes(lotw.String), yes(eqsl.String), yes(paper.String)
|
||||||
|
if cl {
|
||||||
|
s.ConfirmedLoTW++
|
||||||
|
}
|
||||||
|
if el {
|
||||||
|
s.ConfirmedEQSL++
|
||||||
|
}
|
||||||
|
if pl {
|
||||||
|
s.ConfirmedQSL++
|
||||||
|
}
|
||||||
|
if cl || el || pl {
|
||||||
|
s.ConfirmedAny++
|
||||||
|
}
|
||||||
|
|
||||||
|
// An undated QSO still counts in the mode/band/operator totals above, but
|
||||||
|
// it can't be placed on a time axis — leave it out of the trend rather than
|
||||||
|
// parking it at year zero.
|
||||||
|
if !dated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if first.IsZero() || t.Before(first) {
|
||||||
|
first = t
|
||||||
|
}
|
||||||
|
if last.IsZero() || t.After(last) {
|
||||||
|
last = t
|
||||||
|
}
|
||||||
|
yearC[t.Format("2006")]++
|
||||||
|
monthC[t.Format("2006-01")]++
|
||||||
|
times = append(times, entry{t: t, op: op})
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.UniqueCalls = len(calls)
|
||||||
|
s.Entities = len(entities)
|
||||||
|
s.Continents = len(contC)
|
||||||
|
if !first.IsZero() {
|
||||||
|
s.FirstQSO = first.UTC().Format(time.RFC3339)
|
||||||
|
s.LastQSO = last.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.ByMode = topBuckets(modeC, 0)
|
||||||
|
s.ByOperator = topBuckets(opC, 0)
|
||||||
|
s.ByStation = topBuckets(stationC, 0)
|
||||||
|
s.ByContinent = topBuckets(contC, 0)
|
||||||
|
s.TopEntities = topBuckets(entityC, 15)
|
||||||
|
|
||||||
|
// Bands read in band-plan order, not by count — the shape of the chart IS
|
||||||
|
// the band plan, and re-sorting it by size would destroy that.
|
||||||
|
s.ByBand = sortedBuckets(bandC, func(a, b string) bool {
|
||||||
|
oa, ob := bandOrder[a], bandOrder[b]
|
||||||
|
if oa == 0 {
|
||||||
|
oa = 99
|
||||||
|
}
|
||||||
|
if ob == 0 {
|
||||||
|
ob = 99
|
||||||
|
}
|
||||||
|
if oa != ob {
|
||||||
|
return oa < ob
|
||||||
|
}
|
||||||
|
return a < b
|
||||||
|
})
|
||||||
|
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
|
||||||
|
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
|
||||||
|
// chart would invent activity that never happened. A gap in the log is real
|
||||||
|
// information: it belongs on the chart as zeros.
|
||||||
|
s.ByYear = fillYears(yearC, first, last)
|
||||||
|
s.ByMonth = fillMonths(monthC, first, last)
|
||||||
|
|
||||||
|
s.periodMetrics(times, from, to, first, last)
|
||||||
|
s.ensureNonNil()
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureNonNil replaces every nil slice with an empty one.
|
||||||
|
//
|
||||||
|
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
|
||||||
|
// UI then calls .length / .map on null — a TypeError that unmounts the whole React
|
||||||
|
// tree and leaves a WHITE SCREEN. It bites exactly in the innocent cases: a contest
|
||||||
|
// with no break ≥ 30 min (Gaps nil), or a window too long for the hourly chart
|
||||||
|
// (Rate nil). The awards code carries the same guard for the same reason.
|
||||||
|
func (s *Stats) ensureNonNil() {
|
||||||
|
if s.ByMode == nil {
|
||||||
|
s.ByMode = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.ByBand == nil {
|
||||||
|
s.ByBand = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.ByOperator == nil {
|
||||||
|
s.ByOperator = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.ByStation == nil {
|
||||||
|
s.ByStation = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.ByContinent == nil {
|
||||||
|
s.ByContinent = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.TopEntities == nil {
|
||||||
|
s.TopEntities = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.ByYear == nil {
|
||||||
|
s.ByYear = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.ByMonth == nil {
|
||||||
|
s.ByMonth = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.Rate == nil {
|
||||||
|
s.Rate = []Bucket{}
|
||||||
|
}
|
||||||
|
if s.Gaps == nil {
|
||||||
|
s.Gaps = []Gap{}
|
||||||
|
}
|
||||||
|
if s.RateOps == nil {
|
||||||
|
s.RateOps = []string{}
|
||||||
|
}
|
||||||
|
if s.RateByOp == nil {
|
||||||
|
s.RateByOp = [][]int{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// periodMetrics derives the rate / off-air figures that make a contest window
|
||||||
|
// readable. The window is the caller's [from,to] when given, else the span of the
|
||||||
|
// log itself.
|
||||||
|
//
|
||||||
|
// Two rates are reported on purpose, because a single one always flatters:
|
||||||
|
// • AvgPerHour = QSOs ÷ the WHOLE window — breaks included. The honest number.
|
||||||
|
// • AvgPerActive = QSOs ÷ the hours actually operated. Flatters, but tells you
|
||||||
|
// how fast you go when you ARE at the radio.
|
||||||
|
// Quoting only the second is how an 8-hour effort gets sold as a 48-hour score.
|
||||||
|
func (s *Stats) periodMetrics(times []entry, from, to, first, last time.Time) {
|
||||||
|
if len(times) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Slice(times, func(i, j int) bool { return times[i].t.Before(times[j].t) })
|
||||||
|
|
||||||
|
winStart, winEnd := from, to
|
||||||
|
if winStart.IsZero() {
|
||||||
|
winStart = first
|
||||||
|
}
|
||||||
|
if winEnd.IsZero() {
|
||||||
|
winEnd = last
|
||||||
|
}
|
||||||
|
if !winEnd.After(winStart) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.WindowStart = winStart.Format(time.RFC3339)
|
||||||
|
s.WindowEnd = winEnd.Format(time.RFC3339)
|
||||||
|
s.WindowHours = winEnd.Sub(winStart).Hours()
|
||||||
|
if s.WindowHours > 0 {
|
||||||
|
s.AvgPerHour = float64(len(times)) / s.WindowHours
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock-hour buckets — for the rate chart and the best clock hour only. NOT for
|
||||||
|
// "hours on air": a single QSO at 08:05 would book the whole 08:00 hour.
|
||||||
|
hourly := map[string]int{}
|
||||||
|
for _, e := range times {
|
||||||
|
hourly[e.t.Format("2006-01-02 15")]++
|
||||||
|
}
|
||||||
|
for k, v := range hourly {
|
||||||
|
if v > s.PeakHourCount || (v == s.PeakHourCount && k < s.PeakHourKey) {
|
||||||
|
s.PeakHourKey, s.PeakHourCount = k, v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best ROLLING 60 minutes — not the best clock hour. A run straddling 13:45–
|
||||||
|
// 14:45 is invisible to clock-hour bucketing, and it's the figure contesters
|
||||||
|
// actually quote. Two pointers over the sorted times: O(n).
|
||||||
|
lo := 0
|
||||||
|
for hi := range times {
|
||||||
|
for times[hi].t.Sub(times[lo].t) >= time.Hour {
|
||||||
|
lo++
|
||||||
|
}
|
||||||
|
if n := hi - lo + 1; n > s.Best60 {
|
||||||
|
s.Best60 = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off-air is a TIME BUDGET, and it has to close on the window:
|
||||||
|
// OnAirMinutes + OffAirMinutes == window
|
||||||
|
// So every silence ≥ 30 min counts — including the lead-in before the first QSO
|
||||||
|
// and the tail after the last, when an explicit window was asked for. Skipping
|
||||||
|
// those (the first version did) makes "on air" and "off air" sum to more than
|
||||||
|
// the window, and then neither number is believable.
|
||||||
|
addGap := func(a, b time.Time) {
|
||||||
|
d := b.Sub(a)
|
||||||
|
if d < gapThreshold {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.OffAirMinutes += int(d.Minutes())
|
||||||
|
s.Gaps = append(s.Gaps, Gap{
|
||||||
|
Start: a.Format(time.RFC3339),
|
||||||
|
End: b.Format(time.RFC3339),
|
||||||
|
Minutes: int(d.Minutes()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
addGap(winStart, times[0].t) // lead-in
|
||||||
|
for i := 1; i < len(times); i++ { // the silences between QSOs
|
||||||
|
addGap(times[i-1].t, times[i].t)
|
||||||
|
}
|
||||||
|
addGap(times[len(times)-1].t, winEnd) // tail
|
||||||
|
|
||||||
|
s.OnAirMinutes = int(winEnd.Sub(winStart).Minutes()) - s.OffAirMinutes
|
||||||
|
if s.OnAirMinutes < 0 {
|
||||||
|
s.OnAirMinutes = 0
|
||||||
|
}
|
||||||
|
if s.OnAirMinutes > 0 {
|
||||||
|
s.AvgPerActive = float64(len(times)) / (float64(s.OnAirMinutes) / 60)
|
||||||
|
}
|
||||||
|
sort.Slice(s.Gaps, func(i, j int) bool { return s.Gaps[i].Minutes > s.Gaps[j].Minutes })
|
||||||
|
if len(s.Gaps) > 10 {
|
||||||
|
s.Gaps = s.Gaps[:10] // the long ones are the story; the tail is noise
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-hour rate timeline + the RATE SHEET (who made those QSOs, hour by hour).
|
||||||
|
// Every hour of the window, zeros included, so the silences read as silences.
|
||||||
|
if s.WindowHours > rateMaxHours {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operators, busiest first. That order is fixed and reused as the colour/legend
|
||||||
|
// order, so an operator keeps the same hue everywhere on the page — a chart that
|
||||||
|
// repaints its series when the filter changes is a chart nobody can trust.
|
||||||
|
opTotals := map[string]int{}
|
||||||
|
for _, e := range times {
|
||||||
|
opTotals[e.op]++
|
||||||
|
}
|
||||||
|
s.RateOps = make([]string, 0, len(opTotals))
|
||||||
|
for op := range opTotals {
|
||||||
|
s.RateOps = append(s.RateOps, op)
|
||||||
|
}
|
||||||
|
sort.Slice(s.RateOps, func(i, j int) bool {
|
||||||
|
a, b := s.RateOps[i], s.RateOps[j]
|
||||||
|
if opTotals[a] != opTotals[b] {
|
||||||
|
return opTotals[a] > opTotals[b]
|
||||||
|
}
|
||||||
|
return a < b
|
||||||
|
})
|
||||||
|
// Never invent a 9th colour: past 8 operators the tail folds into "Other", which
|
||||||
|
// is honest and still sums correctly.
|
||||||
|
const maxOps = 8
|
||||||
|
folded := false
|
||||||
|
if len(s.RateOps) > maxOps {
|
||||||
|
s.RateOps = append(s.RateOps[:maxOps:maxOps], otherOp)
|
||||||
|
folded = true
|
||||||
|
}
|
||||||
|
opIdx := map[string]int{}
|
||||||
|
for i, op := range s.RateOps {
|
||||||
|
opIdx[op] = i
|
||||||
|
}
|
||||||
|
slotFor := func(op string) int {
|
||||||
|
if i, ok := opIdx[op]; ok {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
if folded {
|
||||||
|
return len(s.RateOps) - 1 // the "Other" bucket
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// hourOps[hourKey][slot] — built from the same `times` as `hourly`, so the
|
||||||
|
// per-operator numbers ALWAYS sum to the hour's total. Deriving them separately
|
||||||
|
// is how a rate sheet ends up not adding up to its own total row.
|
||||||
|
hourOps := map[string][]int{}
|
||||||
|
for _, e := range times {
|
||||||
|
k := e.t.Format("2006-01-02 15")
|
||||||
|
row, ok := hourOps[k]
|
||||||
|
if !ok {
|
||||||
|
row = make([]int, len(s.RateOps))
|
||||||
|
hourOps[k] = row
|
||||||
|
}
|
||||||
|
if i := slotFor(e.op); i >= 0 {
|
||||||
|
row[i]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cur := winStart.Truncate(time.Hour)
|
||||||
|
end := winEnd.Truncate(time.Hour)
|
||||||
|
for !cur.After(end) {
|
||||||
|
k := cur.Format("2006-01-02 15")
|
||||||
|
s.Rate = append(s.Rate, Bucket{Key: cur.Format("01-02 15"), Count: hourly[k]})
|
||||||
|
row := hourOps[k]
|
||||||
|
if row == nil {
|
||||||
|
row = make([]int, len(s.RateOps)) // a silent hour is zeros, not a missing row
|
||||||
|
}
|
||||||
|
s.RateByOp = append(s.RateByOp, row)
|
||||||
|
cur = cur.Add(time.Hour)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// otherOp is where operators past the 8th are folded. Generating a 9th colour is
|
||||||
|
// never the answer: under colour-blindness it is indistinguishable from one of the
|
||||||
|
// existing eight.
|
||||||
|
const otherOp = "Other"
|
||||||
|
|
||||||
|
// topBuckets sorts a count map most → least (ties alphabetical) and optionally
|
||||||
|
// keeps only the top n.
|
||||||
|
func topBuckets(m map[string]int, n int) []Bucket {
|
||||||
|
out := make([]Bucket, 0, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out = append(out, Bucket{Key: k, Count: v})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Count != out[j].Count {
|
||||||
|
return out[i].Count > out[j].Count
|
||||||
|
}
|
||||||
|
return out[i].Key < out[j].Key
|
||||||
|
})
|
||||||
|
if n > 0 && len(out) > n {
|
||||||
|
out = out[:n]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortedBuckets keeps a caller-defined key order (band plan, chronology).
|
||||||
|
func sortedBuckets(m map[string]int, less func(a, b string) bool) []Bucket {
|
||||||
|
keys := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Slice(keys, func(i, j int) bool { return less(keys[i], keys[j]) })
|
||||||
|
out := make([]Bucket, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
out = append(out, Bucket{Key: k, Count: m[k]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// fillMonths emits EVERY month between the first and last QSO — zeros included —
|
||||||
|
// so the trend line's x-axis is real time rather than "months that happen to have
|
||||||
|
// data". A quiet decade must read as a decade at zero, not vanish.
|
||||||
|
func fillMonths(m map[string]int, first, last time.Time) []Bucket {
|
||||||
|
if first.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []Bucket
|
||||||
|
cur := time.Date(first.Year(), first.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
end := time.Date(last.Year(), last.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
for !cur.After(end) {
|
||||||
|
k := cur.Format("2006-01")
|
||||||
|
out = append(out, Bucket{Key: k, Count: m[k]})
|
||||||
|
cur = cur.AddDate(0, 1, 0)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillYears does the same for the yearly view.
|
||||||
|
func fillYears(m map[string]int, first, last time.Time) []Bucket {
|
||||||
|
if first.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []Bucket
|
||||||
|
for y := first.Year(); y <= last.Year(); y++ {
|
||||||
|
k := fmt.Sprintf("%04d", y)
|
||||||
|
out = append(out, Bucket{Key: k, Count: m[k]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package qso
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bands must read in BAND-PLAN order (160m → 70cm), never by count and never
|
||||||
|
// alphabetically — the order of that chart IS the information.
|
||||||
|
func TestBandPlanOrder(t *testing.T) {
|
||||||
|
counts := map[string]int{"20m": 9312, "160m": 77, "70cm": 3, "40m": 5196, "10m": 3401, "80m": 2332}
|
||||||
|
got := sortedBuckets(counts, func(a, b string) bool {
|
||||||
|
oa, ob := bandOrder[a], bandOrder[b]
|
||||||
|
if oa == 0 {
|
||||||
|
oa = 99
|
||||||
|
}
|
||||||
|
if ob == 0 {
|
||||||
|
ob = 99
|
||||||
|
}
|
||||||
|
if oa != ob {
|
||||||
|
return oa < ob
|
||||||
|
}
|
||||||
|
return a < b
|
||||||
|
})
|
||||||
|
want := []string{"160m", "80m", "40m", "20m", "10m", "70cm"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %d buckets, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i].Key != want[i] {
|
||||||
|
t.Errorf("position %d = %q, want %q (full: %v)", i, got[i].Key, want[i], got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A nil Go slice marshals to JSON `null`, not `[]` — and the UI then calls
|
||||||
|
// .length/.map on null, which unmounts the whole React tree and leaves a WHITE
|
||||||
|
// SCREEN. It bites in the innocent cases: a contest with no break ≥ 30 min (Gaps
|
||||||
|
// nil), or a window too long for the hourly chart (Rate nil). Every slice the
|
||||||
|
// dashboard reads must therefore come back non-nil, even when empty.
|
||||||
|
func TestStatsNoNilSlices(t *testing.T) {
|
||||||
|
var s Stats // the worst case: nothing computed at all
|
||||||
|
s.ensureNonNil()
|
||||||
|
|
||||||
|
checks := map[string]bool{
|
||||||
|
"ByMode": s.ByMode == nil, "ByBand": s.ByBand == nil, "ByOperator": s.ByOperator == nil,
|
||||||
|
"ByStation": s.ByStation == nil, "ByContinent": s.ByContinent == nil,
|
||||||
|
"TopEntities": s.TopEntities == nil, "ByYear": s.ByYear == nil, "ByMonth": s.ByMonth == nil,
|
||||||
|
"Rate": s.Rate == nil, "Gaps": s.Gaps == nil,
|
||||||
|
}
|
||||||
|
for name, isNil := range checks {
|
||||||
|
if isNil {
|
||||||
|
t.Errorf("%s is nil → marshals to JSON null → white screen in the UI", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The realistic trigger: a short, gap-free run. No silence ≥ 30 min and a
|
||||||
|
// window that yields no hourly chart must still produce [] and not null.
|
||||||
|
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||||
|
times := []entry{{t: base, op: "A"}, {t: base.Add(2 * time.Minute), op: "A"}, {t: base.Add(5 * time.Minute), op: "B"}}
|
||||||
|
var s2 Stats
|
||||||
|
s2.periodMetrics(times, time.Time{}, time.Time{}, base, base.Add(5*time.Minute))
|
||||||
|
s2.ensureNonNil()
|
||||||
|
if s2.Gaps == nil {
|
||||||
|
t.Error("Gaps nil for a gap-free run — this is exactly the contest that white-screened")
|
||||||
|
}
|
||||||
|
if len(s2.Gaps) != 0 {
|
||||||
|
t.Errorf("Gaps = %v, want empty (no silence ≥ 30 min in this run)", s2.Gaps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contest metrics over a window. The two traps:
|
||||||
|
// 1. "Best hour" must be the best ROLLING 60 minutes, not the best clock hour —
|
||||||
|
// a run straddling 13:45–14:45 is invisible to clock-hour bucketing, and the
|
||||||
|
// rolling figure is the one contesters quote.
|
||||||
|
// 2. Both rates must be reported: QSOs ÷ whole window (honest, breaks included)
|
||||||
|
// AND QSOs ÷ hours actually operated. Quoting only the latter is how an
|
||||||
|
// 8-hour effort gets sold as a 48-hour score.
|
||||||
|
func TestContestPeriodMetrics(t *testing.T) {
|
||||||
|
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||||
|
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
|
||||||
|
|
||||||
|
// A run straddling the clock hour: 10 QSOs from 12:40 to 13:20 (within 60 min),
|
||||||
|
// then a 2-hour silence, then 3 more.
|
||||||
|
var times []entry
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
times = append(times, entry{t: at(40 + i*4), op: "F4BPO"}) // 12:40 … 13:16
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
times = append(times, entry{t: at(240 + i*5), op: "F5XYZ"}) // 16:00 …
|
||||||
|
}
|
||||||
|
|
||||||
|
from := base // 12:00
|
||||||
|
to := base.Add(6 * time.Hour) // 18:00 → a 6-hour window
|
||||||
|
var s Stats
|
||||||
|
s.periodMetrics(times, from, to, time.Time{}, time.Time{})
|
||||||
|
|
||||||
|
if s.WindowHours != 6 {
|
||||||
|
t.Errorf("window = %.1f h, want 6", s.WindowHours)
|
||||||
|
}
|
||||||
|
// 13 QSOs over a 6 h window.
|
||||||
|
if got := s.AvgPerHour; got < 2.16 || got > 2.17 {
|
||||||
|
t.Errorf("avg/h over the window = %.3f, want ~2.167 (13÷6)", got)
|
||||||
|
}
|
||||||
|
// The rolling hour must find the straddling run of 10 — a clock-hour bucket
|
||||||
|
// would only ever see part of it.
|
||||||
|
if s.Best60 != 10 {
|
||||||
|
t.Errorf("best rolling 60 min = %d, want 10 (the 12:40→13:16 run)", s.Best60)
|
||||||
|
}
|
||||||
|
if s.PeakHourCount >= 10 {
|
||||||
|
t.Errorf("peak CLOCK hour = %d — it should be < 10, which is exactly why the rolling figure exists", s.PeakHourCount)
|
||||||
|
}
|
||||||
|
// THE INVARIANT: on-air + off-air must close on the window. The first version
|
||||||
|
// counted "clock hours containing a QSO" as on-air, which on a real 45 h contest
|
||||||
|
// reported 39 h on air AND 16 h 43 off air — 56 h inside 45 h. Two numbers on
|
||||||
|
// incompatible bases; the operator believed neither, and was right.
|
||||||
|
if got := s.OnAirMinutes + s.OffAirMinutes; got != int(s.WindowHours*60) {
|
||||||
|
t.Errorf("on-air (%d) + off-air (%d) = %d min, but the window is %d min — the budget must close",
|
||||||
|
s.OnAirMinutes, s.OffAirMinutes, got, int(s.WindowHours*60))
|
||||||
|
}
|
||||||
|
// Off air = lead-in (12:00→12:40 = 40 min) + the 13:16→16:00 silence (164) +
|
||||||
|
// the tail (16:10→18:00 = 110). Silences ≥ 30 min all count, wherever they sit:
|
||||||
|
// ignoring the lead-in and tail is what broke the budget.
|
||||||
|
if s.OffAirMinutes != 40+164+110 {
|
||||||
|
t.Errorf("off-air = %d min, want %d (lead-in + gap + tail)", s.OffAirMinutes, 40+164+110)
|
||||||
|
}
|
||||||
|
if len(s.Gaps) != 3 {
|
||||||
|
t.Fatalf("gaps = %+v, want 3 (lead-in, the silence, the tail)", s.Gaps)
|
||||||
|
}
|
||||||
|
if s.AvgPerActive <= s.AvgPerHour {
|
||||||
|
t.Errorf("avg/on-air (%.2f) must exceed avg/window (%.2f) when there are breaks", s.AvgPerActive, s.AvgPerHour)
|
||||||
|
}
|
||||||
|
// The rate timeline covers EVERY hour of the window, silences as zeros.
|
||||||
|
if len(s.Rate) != 7 { // 12,13,14,15,16,17,18
|
||||||
|
t.Fatalf("rate timeline = %d hours, want 7 (every hour of the window)", len(s.Rate))
|
||||||
|
}
|
||||||
|
if s.Rate[2].Count != 0 || s.Rate[3].Count != 0 {
|
||||||
|
t.Errorf("the 14:00/15:00 silence must show as zeros, got %+v %+v", s.Rate[2], s.Rate[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The contest RATE SHEET: hour by hour, who made the QSOs.
|
||||||
|
//
|
||||||
|
// The invariant that matters: for EVERY hour, the per-operator numbers must sum to
|
||||||
|
// that hour's total. Derive the two separately and a rate sheet quietly stops
|
||||||
|
// adding up to its own total row — the sort of error nobody spots until someone
|
||||||
|
// checks the score by hand.
|
||||||
|
func TestRateSheetSumsToHourTotal(t *testing.T) {
|
||||||
|
base := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
|
||||||
|
at := func(min int) time.Time { return base.Add(time.Duration(min) * time.Minute) }
|
||||||
|
|
||||||
|
times := []entry{
|
||||||
|
{t: at(5), op: "F4BPO"}, {t: at(10), op: "F4BPO"}, {t: at(20), op: "F5XYZ"}, // hour 12: 3
|
||||||
|
{t: at(70), op: "F5XYZ"}, {t: at(80), op: "F5XYZ"}, // hour 13: 2
|
||||||
|
// hour 14 silent
|
||||||
|
{t: at(185), op: "F4BPO"}, // hour 15: 1
|
||||||
|
}
|
||||||
|
var s Stats
|
||||||
|
s.periodMetrics(times, base, base.Add(4*time.Hour), time.Time{}, time.Time{})
|
||||||
|
|
||||||
|
if len(s.Rate) != len(s.RateByOp) {
|
||||||
|
t.Fatalf("rate rows (%d) and rate-sheet rows (%d) must align 1:1", len(s.Rate), len(s.RateByOp))
|
||||||
|
}
|
||||||
|
// Both operators present, busiest first (they tie at 3 → alphabetical).
|
||||||
|
if len(s.RateOps) != 2 || s.RateOps[0] != "F4BPO" {
|
||||||
|
t.Fatalf("rate ops = %v, want [F4BPO F5XYZ]", s.RateOps)
|
||||||
|
}
|
||||||
|
for h := range s.Rate {
|
||||||
|
sum := 0
|
||||||
|
for _, n := range s.RateByOp[h] {
|
||||||
|
sum += n
|
||||||
|
}
|
||||||
|
if sum != s.Rate[h].Count {
|
||||||
|
t.Errorf("hour %s: operators sum to %d but the hour total is %d — the rate sheet doesn't add up",
|
||||||
|
s.Rate[h].Key, sum, s.Rate[h].Count)
|
||||||
|
}
|
||||||
|
if len(s.RateByOp[h]) != len(s.RateOps) {
|
||||||
|
t.Errorf("hour %s: row has %d columns, want %d (one per operator)", s.Rate[h].Key, len(s.RateByOp[h]), len(s.RateOps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The silent hour is a row of zeros, not a missing row.
|
||||||
|
if s.Rate[2].Count != 0 || s.RateByOp[2][0] != 0 || s.RateByOp[2][1] != 0 {
|
||||||
|
t.Errorf("the silent 14:00 hour must be zeros, got total=%d row=%v", s.Rate[2].Count, s.RateByOp[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A quiet decade must appear on the trend as a decade AT ZERO. Emitting only the
|
||||||
|
// months that have QSOs would put 2012 next to 2022 as if consecutive — the chart
|
||||||
|
// would invent activity that never happened.
|
||||||
|
func TestTimeAxisIsContinuous(t *testing.T) {
|
||||||
|
first := time.Date(2009, 5, 30, 0, 0, 0, 0, time.UTC)
|
||||||
|
last := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
years := fillYears(map[string]int{"2009": 79, "2012": 1187, "2026": 14415}, first, last)
|
||||||
|
if len(years) != 18 { // 2009..2026 inclusive
|
||||||
|
t.Fatalf("years = %d, want 18 (2009→2026 with no holes)", len(years))
|
||||||
|
}
|
||||||
|
byKey := map[string]int{}
|
||||||
|
for _, b := range years {
|
||||||
|
byKey[b.Key] = b.Count
|
||||||
|
}
|
||||||
|
if byKey["2010"] != 0 || byKey["2018"] != 0 {
|
||||||
|
t.Errorf("silent years must be present as zero, got 2010=%d 2018=%d", byKey["2010"], byKey["2018"])
|
||||||
|
}
|
||||||
|
if byKey["2012"] != 1187 || byKey["2026"] != 14415 {
|
||||||
|
t.Errorf("real counts lost: 2012=%d 2026=%d", byKey["2012"], byKey["2026"])
|
||||||
|
}
|
||||||
|
|
||||||
|
months := fillMonths(map[string]int{"2009-05": 49, "2026-07": 1}, first, last)
|
||||||
|
// May 2009 → July 2026 inclusive = 17 years * 12 + 3 = 207 months.
|
||||||
|
if len(months) != 207 {
|
||||||
|
t.Fatalf("months = %d, want 207 (continuous)", len(months))
|
||||||
|
}
|
||||||
|
if months[0].Key != "2009-05" || months[0].Count != 49 {
|
||||||
|
t.Errorf("first month = %+v, want 2009-05 / 49", months[0])
|
||||||
|
}
|
||||||
|
if months[len(months)-1].Key != "2026-07" {
|
||||||
|
t.Errorf("last month = %q, want 2026-07", months[len(months)-1].Key)
|
||||||
|
}
|
||||||
|
// Every step is exactly one month — no jumps.
|
||||||
|
for i := 1; i < len(months); i++ {
|
||||||
|
prev, _ := time.Parse("2006-01", months[i-1].Key)
|
||||||
|
cur, _ := time.Parse("2006-01", months[i].Key)
|
||||||
|
if !prev.AddDate(0, 1, 0).Equal(cur) {
|
||||||
|
t.Fatalf("gap in the time axis between %q and %q", months[i-1].Key, months[i].Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Package solar fetches live space-weather / propagation numbers (solar flux,
|
||||||
|
// sunspot number, A/K indices, X-ray, geomagnetic field) from N0NBH's public
|
||||||
|
// hamqsl.com XML feed, caches them, and refreshes periodically. OpsLog shows the
|
||||||
|
// numbers in a header strip and stamps SFI / A_INDEX / K_INDEX onto each logged
|
||||||
|
// QSO (SSN has no standard ADIF field — the app stores it as an extra).
|
||||||
|
package solar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// feedURL is N0NBH's solar data as XML. Free, no key.
|
||||||
|
const feedURL = "https://www.hamqsl.com/solarxml.php"
|
||||||
|
|
||||||
|
// Data is the parsed snapshot exposed to the UI (strings — some fields read
|
||||||
|
// "No Report" or carry units, so we keep them verbatim and parse to numbers only
|
||||||
|
// when stamping a QSO).
|
||||||
|
type Data struct {
|
||||||
|
SFI string `json:"sfi"` // solar flux index
|
||||||
|
SSN string `json:"ssn"` // sunspot number
|
||||||
|
AIndex string `json:"a_index"` // geomagnetic A index
|
||||||
|
KIndex string `json:"k_index"` // geomagnetic K index
|
||||||
|
XRay string `json:"xray"` // X-ray flux class (e.g. C1.5)
|
||||||
|
GeomagField string `json:"geomag_field"` // QUIET / UNSETTLED / STORM…
|
||||||
|
Aurora string `json:"aurora"`
|
||||||
|
MUF string `json:"muf"`
|
||||||
|
Updated string `json:"updated"` // source timestamp text
|
||||||
|
Source string `json:"source"` // e.g. N0NBH
|
||||||
|
OK bool `json:"ok"` // last fetch succeeded
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
FetchedAt time.Time `json:"fetched_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// xmlSolar mirrors the hamqsl solarxml.php document.
|
||||||
|
type xmlSolar struct {
|
||||||
|
XMLName xml.Name `xml:"solar"`
|
||||||
|
SolarData struct {
|
||||||
|
Source string `xml:"source"`
|
||||||
|
Updated string `xml:"updated"`
|
||||||
|
SolarFlux string `xml:"solarflux"`
|
||||||
|
AIndex string `xml:"aindex"`
|
||||||
|
KIndex string `xml:"kindex"`
|
||||||
|
XRay string `xml:"xray"`
|
||||||
|
Sunspots string `xml:"sunspots"`
|
||||||
|
Aurora string `xml:"aurora"`
|
||||||
|
GeomagField string `xml:"geomagfield"`
|
||||||
|
MUF string `xml:"muf"`
|
||||||
|
} `xml:"solardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch performs one GET + parse of the feed.
|
||||||
|
func Fetch(ctx context.Context, client *http.Client) (Data, error) {
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 20 * time.Second}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return Data{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "OpsLog")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Data{}, fmt.Errorf("solar: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return Data{}, fmt.Errorf("solar: read: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return Data{}, fmt.Errorf("solar: http %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return parseSolar(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSolar turns the feed's XML bytes into a Data (split out so it can be
|
||||||
|
// unit-tested without a network call).
|
||||||
|
func parseSolar(body []byte) (Data, error) {
|
||||||
|
var x xmlSolar
|
||||||
|
if err := xml.Unmarshal(body, &x); err != nil {
|
||||||
|
return Data{}, fmt.Errorf("solar: parse: %w", err)
|
||||||
|
}
|
||||||
|
s := x.SolarData
|
||||||
|
return Data{
|
||||||
|
SFI: strings.TrimSpace(s.SolarFlux),
|
||||||
|
SSN: strings.TrimSpace(s.Sunspots),
|
||||||
|
AIndex: strings.TrimSpace(s.AIndex),
|
||||||
|
KIndex: strings.TrimSpace(s.KIndex),
|
||||||
|
XRay: strings.TrimSpace(s.XRay),
|
||||||
|
GeomagField: strings.TrimSpace(s.GeomagField),
|
||||||
|
Aurora: strings.TrimSpace(s.Aurora),
|
||||||
|
MUF: strings.TrimSpace(s.MUF),
|
||||||
|
Updated: strings.TrimSpace(s.Updated),
|
||||||
|
Source: strings.TrimSpace(s.Source),
|
||||||
|
OK: true,
|
||||||
|
FetchedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager caches the latest Data and refreshes it on a timer.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
data Data
|
||||||
|
client *http.Client
|
||||||
|
onChange func()
|
||||||
|
stop chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager builds a manager. onChange (optional) fires after each refresh so
|
||||||
|
// the host can push a UI event.
|
||||||
|
func NewManager(onChange func()) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
client: &http.Client{Timeout: 20 * time.Second},
|
||||||
|
onChange: onChange,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start fetches now and every hour until Stop (the feed updates roughly hourly).
|
||||||
|
func (m *Manager) Start() {
|
||||||
|
go func() {
|
||||||
|
m.refresh()
|
||||||
|
t := time.NewTicker(1 * time.Hour)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
m.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop ends the refresh loop.
|
||||||
|
func (m *Manager) Stop() {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.once.Do(func() { close(m.stop) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the latest snapshot.
|
||||||
|
func (m *Manager) Get() Data {
|
||||||
|
if m == nil {
|
||||||
|
return Data{}
|
||||||
|
}
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh forces an immediate fetch (used by a manual "refresh" binding).
|
||||||
|
func (m *Manager) Refresh() { m.refresh() }
|
||||||
|
|
||||||
|
func (m *Manager) refresh() {
|
||||||
|
d, err := Fetch(context.Background(), m.client)
|
||||||
|
m.mu.Lock()
|
||||||
|
if err != nil {
|
||||||
|
m.data.OK = false
|
||||||
|
m.data.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
m.data = d
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
if m.onChange != nil {
|
||||||
|
m.onChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package solar
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A trimmed sample of the real N0NBH hamqsl.com solarxml.php document.
|
||||||
|
const sampleXML = `<?xml version="1.0"?>
|
||||||
|
<solar>
|
||||||
|
<solardata>
|
||||||
|
<source url="http://www.hamqsl.com/solar.html">N0NBH</source>
|
||||||
|
<updated>08 Jul 2025 2100 GMT</updated>
|
||||||
|
<solarflux>170</solarflux>
|
||||||
|
<aindex>5</aindex>
|
||||||
|
<kindex>2</kindex>
|
||||||
|
<xray>C1.5</xray>
|
||||||
|
<sunspots>150</sunspots>
|
||||||
|
<geomagfield>QUIET</geomagfield>
|
||||||
|
<muf>18.5</muf>
|
||||||
|
</solardata>
|
||||||
|
</solar>`
|
||||||
|
|
||||||
|
func TestParseSolar(t *testing.T) {
|
||||||
|
d, err := parseSolar([]byte(sampleXML))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if !d.OK {
|
||||||
|
t.Fatalf("expected OK")
|
||||||
|
}
|
||||||
|
checks := map[string]string{
|
||||||
|
"SFI": d.SFI, "SSN": d.SSN, "A": d.AIndex, "K": d.KIndex,
|
||||||
|
"XRay": d.XRay, "Geomag": d.GeomagField, "Source": d.Source,
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"SFI": "170", "SSN": "150", "A": "5", "K": "2",
|
||||||
|
"XRay": "C1.5", "Geomag": "QUIET", "Source": "N0NBH",
|
||||||
|
}
|
||||||
|
for k, got := range checks {
|
||||||
|
if got != want[k] {
|
||||||
|
t.Errorf("%s = %q, want %q", k, got, want[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSolarBadXML(t *testing.T) {
|
||||||
|
if _, err := parseSolar([]byte("not xml")); err == nil {
|
||||||
|
t.Fatalf("expected an error for non-XML input")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// After a Home/Retract the controller drops out of AUTOTRACK and ignores
|
||||||
|
// frequency sets until it is turned back ON. Set on Retract, cleared by
|
||||||
|
// re-enabling on the next SetFrequency.
|
||||||
|
needAutotrack bool
|
||||||
|
|
||||||
|
stopChan chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
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==0xFF means "command just received" (not motion); the 0x01 bit is
|
||||||
|
// documented as always set. Treat anything else non-zero as motors busy.
|
||||||
|
moving := 0
|
||||||
|
if active != 0xFF && (active & ^byte(0x01)) != 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")
|
||||||
|
}
|
||||||
|
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. If a prior
|
||||||
|
// Retract dropped AUTOTRACK, re-enable it first — otherwise the set is ignored.
|
||||||
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||||
|
if c.needAutotrack {
|
||||||
|
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.needAutotrack = false
|
||||||
|
}
|
||||||
|
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil {
|
||||||
|
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 leaves AUTOTRACK off,
|
||||||
|
// so the next SetFrequency re-enables it.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'S')); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.needAutotrack = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStatus decodes what the controller sends back — the inverse of buildSet's
|
||||||
|
// frequency field, plus the direction nibble.
|
||||||
|
func TestParseStatus(t *testing.T) {
|
||||||
|
frame := []byte{0x00, 0x00, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '1', '2', 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -75,6 +76,20 @@ type Client struct {
|
|||||||
pendingDir int
|
pendingDir int
|
||||||
pendingDirAt time.Time
|
pendingDirAt time.Time
|
||||||
pendingDirSet bool
|
pendingDirSet bool
|
||||||
|
|
||||||
|
// lastSetKHz is the frequency we last COMMANDED. Used as the follow-loop
|
||||||
|
// deadband reference when the antenna's own status hasn't reported a frequency
|
||||||
|
// yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY
|
||||||
|
// re-tunes the motors.
|
||||||
|
lastSetKHz int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastSetKHz returns the frequency (kHz) most recently commanded to the antenna,
|
||||||
|
// or 0 if none yet.
|
||||||
|
func (c *Client) LastSetKHz() int {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.lastSetKHz
|
||||||
}
|
}
|
||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
@@ -457,6 +472,15 @@ func (c *Client) queryProgress() ([]int, error) {
|
|||||||
|
|
||||||
// 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
|
||||||
|
// unexpected antenna QSY (e.g. jumping to 14.074 while on 40m) can be traced
|
||||||
|
// to the follow loop, an immediate re-tune, or a direction re-issue.
|
||||||
|
caller := "?"
|
||||||
|
if pc, _, line, ok := runtime.Caller(1); ok {
|
||||||
|
caller = fmt.Sprintf("%s:%d", runtime.FuncForPC(pc).Name(), line)
|
||||||
|
}
|
||||||
|
log.Printf("Ultrabeam: SetFrequency(%d kHz, dir %d) ← %s", freqKhz, direction, caller)
|
||||||
|
|
||||||
data := []byte{
|
data := []byte{
|
||||||
byte(freqKhz & 0xFF),
|
byte(freqKhz & 0xFF),
|
||||||
byte((freqKhz >> 8) & 0xFF),
|
byte((freqKhz >> 8) & 0xFF),
|
||||||
@@ -467,6 +491,7 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
||||||
|
c.lastSetKHz = freqKhz
|
||||||
if c.lastStatus != nil {
|
if c.lastStatus != nil {
|
||||||
c.lastStatus.Direction = direction // reflect immediately
|
c.lastStatus.Direction = direction // reflect immediately
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,23 +36,53 @@ func profileArg(args []string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Single-instance guard: if OpsLog is already running, focus that window and
|
||||||
|
// exit instead of spawning a duplicate. A second process would open its own
|
||||||
|
// CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would
|
||||||
|
// fight over the rig/antenna frequency — the cause of "the antenna re-tunes on
|
||||||
|
// its own" when a windowless zombie instance was left running.
|
||||||
|
if !acquireSingleInstance() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Create an instance of the app structure
|
// Create an instance of the app structure
|
||||||
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{}{
|
||||||
|
|||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/db"
|
||||||
|
"hamlog/internal/offlineq"
|
||||||
|
"hamlog/internal/qso"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Offline safety net ────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// With the shared MySQL logbook, the network sits on the critical path of every
|
||||||
|
// write: lose it and you can't log at all. Rather than a fragile "fall back to
|
||||||
|
// SQLite" (which would leave you with a partial log and needs a restart), we
|
||||||
|
// keep the architecture exactly as it is and add a NET:
|
||||||
|
//
|
||||||
|
// DB unreachable → the QSO is parked in a local ADIF outbox (never lost)
|
||||||
|
// DB back → replayed into the logbook, idempotently, file archived
|
||||||
|
//
|
||||||
|
// It only ever PUSHES your own QSOs — no mirror, no pull, no merge. That's what
|
||||||
|
// keeps it small. Accepted trade-off: while offline you don't see other ops'
|
||||||
|
// QSOs, and worked-before doesn't know about your own pending ones (they're
|
||||||
|
// listed separately in the UI instead).
|
||||||
|
|
||||||
|
// offlineReplayEvery is how often we re-probe the database while QSOs are
|
||||||
|
// waiting. Short enough to feel automatic, long enough not to hammer a dead host.
|
||||||
|
const offlineReplayEvery = 15 * time.Second
|
||||||
|
|
||||||
|
// OfflineStatus is what the UI shows: are we parked, and how much is waiting.
|
||||||
|
type OfflineStatus struct {
|
||||||
|
Offline bool `json:"offline"` // last write failed because the DB was unreachable
|
||||||
|
Pending int `json:"pending"` // QSOs sitting in the outbox
|
||||||
|
Path string `json:"path"` // where the outbox physically is
|
||||||
|
}
|
||||||
|
|
||||||
|
// queueOffline parks a QSO that couldn't reach the database. Returns false when
|
||||||
|
// queueing isn't applicable (local SQLite can't "go offline") or the write to the
|
||||||
|
// outbox itself failed — in which case the caller MUST surface the original error
|
||||||
|
// rather than pretend the QSO was saved.
|
||||||
|
func (a *App) queueOffline(q qso.QSO, cause error) bool {
|
||||||
|
if a.offlineQ == nil || !db.IsMySQL() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
qid, err := a.offlineQ.Append(q)
|
||||||
|
if err != nil {
|
||||||
|
// The net itself tore: do NOT claim success.
|
||||||
|
applog.Printf("offline: FAILED to park %s in the outbox: %v (original: %v)", q.Callsign, err, cause)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
applog.Printf("offline: DB unreachable (%v) — parked %s in the outbox (id %s)", cause, q.Callsign, qid)
|
||||||
|
a.offlineMode = true
|
||||||
|
a.emitOfflineStatus()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitOfflineStatus pushes the banner state to the UI.
|
||||||
|
func (a *App) emitOfflineStatus() {
|
||||||
|
if a.ctx == nil || a.offlineQ == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wruntime.EventsEmit(a.ctx, "offline:status", a.GetOfflineStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOfflineStatus reports whether we're parked and how many QSOs are waiting.
|
||||||
|
func (a *App) GetOfflineStatus() OfflineStatus {
|
||||||
|
if a.offlineQ == nil {
|
||||||
|
return OfflineStatus{}
|
||||||
|
}
|
||||||
|
n := a.offlineQ.Count()
|
||||||
|
return OfflineStatus{
|
||||||
|
Offline: a.offlineMode && n > 0,
|
||||||
|
Pending: n,
|
||||||
|
Path: a.offlineQ.Path(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingQSOs lists the QSOs waiting in the outbox, so the operator can SEE
|
||||||
|
// what they logged while the database was down instead of flying blind.
|
||||||
|
func (a *App) GetPendingQSOs() ([]qso.QSO, error) {
|
||||||
|
if a.offlineQ == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return a.offlineQ.Pending()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryOfflineSync forces a replay attempt now (the "Retry" button) instead of
|
||||||
|
// waiting for the next tick.
|
||||||
|
func (a *App) RetryOfflineSync() (int, error) {
|
||||||
|
return a.replayOfflineQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
// offlineReplayLoop re-probes the database while QSOs are waiting and replays
|
||||||
|
// them the moment it answers.
|
||||||
|
func (a *App) offlineReplayLoop() {
|
||||||
|
t := time.NewTicker(offlineReplayEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
if a.offlineQ == nil || a.offlineQ.Count() == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a.logDb == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
err := a.logDb.PingContext(ctx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
continue // still down — keep waiting, the QSOs are safe on disk
|
||||||
|
}
|
||||||
|
if n, rerr := a.replayOfflineQueue(); rerr != nil {
|
||||||
|
applog.Printf("offline: replay failed: %v", rerr)
|
||||||
|
} else if n > 0 {
|
||||||
|
applog.Printf("offline: replayed %d QSO(s) into the logbook", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replayOfflineQueue imports the outbox into the logbook. It is IDEMPOTENT: each
|
||||||
|
// parked QSO carries a queue id, and one already present in the log is skipped —
|
||||||
|
// so a crash between "inserted" and "removed from the file" can never duplicate a
|
||||||
|
// contact. The file is archived BEFORE being cleared.
|
||||||
|
func (a *App) replayOfflineQueue() (int, error) {
|
||||||
|
if a.offlineQ == nil || a.qso == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
pending, err := a.offlineQ.Pending()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(pending) == 0 {
|
||||||
|
a.offlineMode = false
|
||||||
|
a.emitOfflineStatus()
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
imported := 0
|
||||||
|
var failed []qso.QSO
|
||||||
|
for _, q := range pending {
|
||||||
|
qid := ""
|
||||||
|
if q.Extras != nil {
|
||||||
|
qid = q.Extras[qso.OfflineQueueKey]
|
||||||
|
}
|
||||||
|
// Idempotency guard: already replayed on an earlier (interrupted) pass?
|
||||||
|
if qid != "" {
|
||||||
|
if exists, e := a.qso.ExistsByQueueID(a.ctx, qid); e == nil && exists {
|
||||||
|
imported++ // it IS in the log — treat as done, drop from the outbox
|
||||||
|
continue
|
||||||
|
} else if e != nil && db.IsConnLost(e) {
|
||||||
|
failed = append(failed, q) // DB went away again mid-replay
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, e := a.qso.Add(a.ctx, q); e != nil {
|
||||||
|
applog.Printf("offline: replay of %s failed: %v", q.Callsign, e)
|
||||||
|
failed = append(failed, q)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
imported++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive the outbox as it was, then keep only what still failed.
|
||||||
|
if imported > 0 {
|
||||||
|
if dst, e := a.offlineQ.Archive(); e != nil {
|
||||||
|
applog.Printf("offline: archive failed: %v", e)
|
||||||
|
} else if dst != "" {
|
||||||
|
applog.Printf("offline: outbox archived to %s", dst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e := a.offlineQ.Rewrite(failed); e != nil {
|
||||||
|
return imported, fmt.Errorf("offline: rewrite outbox: %w", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.offlineMode = len(failed) > 0
|
||||||
|
a.emitOfflineStatus()
|
||||||
|
if imported > 0 && a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "logbook:changed")
|
||||||
|
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf("%d QSO(s) en attente ont été enregistrés", imported))
|
||||||
|
}
|
||||||
|
return imported, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newOfflineQueue builds the outbox in OpsLog's data directory — deliberately NOT
|
||||||
|
// in a cloud-synced folder: byte-level file replication (Seafile/OneDrive) is the
|
||||||
|
// very thing this design avoids.
|
||||||
|
func newOfflineQueue(dataDir string) *offlineq.Queue { return offlineq.New(dataDir) }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !windows || bindings
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
// acquireSingleInstance is a no-op off Windows (the guard uses a Windows named
|
||||||
|
// mutex), and during Wails' binding generation (the `bindings` tag) — that step
|
||||||
|
// runs this binary, and a real OpsLog already running would otherwise make it
|
||||||
|
// exit before Wails could reflect the bindings.
|
||||||
|
func acquireSingleInstance() bool { return true }
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//go:build windows && !bindings
|
||||||
|
|
||||||
|
// NB the !bindings tag: Wails generates the TypeScript bindings by BUILDING AND
|
||||||
|
// RUNNING this binary. With the guard active, a normal OpsLog already running on
|
||||||
|
// the dev machine holds the mutex, the generator's process exits instantly, and
|
||||||
|
// no bindings are produced. Excluding the guard from that build keeps generation
|
||||||
|
// working while shipping builds still get it.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// singleInstanceName is a per-session named mutex. The Windows kernel releases
|
||||||
|
// it automatically when the owning process dies (even on a crash), so a
|
||||||
|
// lingering/zombie OpsLog can't permanently block future launches — killing it
|
||||||
|
// frees the name at once. Session-local (no "Global\\") = one instance per
|
||||||
|
// logged-in desktop, which is what we want.
|
||||||
|
const singleInstanceName = "OpsLog-SingleInstance-Mutex"
|
||||||
|
|
||||||
|
// acquireSingleInstance creates the named mutex. Returns ok=false when another
|
||||||
|
// OpsLog already holds it (this instance should exit); on the way out it brings
|
||||||
|
// the existing window to the front so a double-click just refocuses OpsLog
|
||||||
|
// instead of spawning a duplicate that fights over the CAT / antenna.
|
||||||
|
//
|
||||||
|
// The mutex handle is deliberately never closed — it must live for the whole
|
||||||
|
// process lifetime; the OS reclaims it on exit.
|
||||||
|
func acquireSingleInstance() (ok bool) {
|
||||||
|
namePtr, err := windows.UTF16PtrFromString(singleInstanceName)
|
||||||
|
if err != nil {
|
||||||
|
return true // never block launch on an unexpected error
|
||||||
|
}
|
||||||
|
kernel32 := windows.NewLazySystemDLL("kernel32.dll")
|
||||||
|
createMutex := kernel32.NewProc("CreateMutexW")
|
||||||
|
// CreateMutexW(lpSecurityAttributes=NULL, bInitialOwner=FALSE, lpName)
|
||||||
|
h, _, callErr := createMutex.Call(0, 0, uintptr(unsafe.Pointer(namePtr)))
|
||||||
|
if h == 0 {
|
||||||
|
return true // couldn't create the mutex → don't block the app
|
||||||
|
}
|
||||||
|
if errors.Is(callErr, windows.ERROR_ALREADY_EXISTS) {
|
||||||
|
focusExistingWindow()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// focusExistingWindow finds the running OpsLog window by its title and restores
|
||||||
|
// + foregrounds it. Best-effort; failures are silently ignored.
|
||||||
|
func focusExistingWindow() {
|
||||||
|
user32 := windows.NewLazySystemDLL("user32.dll")
|
||||||
|
findWindow := user32.NewProc("FindWindowW")
|
||||||
|
setForeground := user32.NewProc("SetForegroundWindow")
|
||||||
|
showWindow := user32.NewProc("ShowWindow")
|
||||||
|
|
||||||
|
title, err := windows.UTF16PtrFromString("OpsLog")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hwnd, _, _ := findWindow.Call(0, uintptr(unsafe.Pointer(title)))
|
||||||
|
if hwnd == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const swRestore = 9 // SW_RESTORE — un-minimise if needed
|
||||||
|
showWindow.Call(hwnd, swRestore)
|
||||||
|
setForeground.Call(hwnd)
|
||||||
|
}
|
||||||
+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.1"
|
appVersion = "0.19.7"
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Amplifiers and Switches
|
||||||
|
|
||||||
|
## PowerGenius XL (4O3A) amplifier
|
||||||
|
|
||||||
|
Direct TCP connection (Settings → PowerGenius):
|
||||||
|
|
||||||
|
- **Operate / Standby** and **fault** display.
|
||||||
|
- **Fan-mode** selector (Standard / Contest / Broadcast).
|
||||||
|
- When a Flex is connected, the amp controls + its **FWD / ID / TEMP** meters
|
||||||
|
appear in the FlexRadio panel's Amplifier card. See [[FlexRadio]].
|
||||||
|
|
||||||
|
## Antenna Genius (4O3A) antenna switch
|
||||||
|
|
||||||
|
Over TCP / GSCP — a docked **A/B antenna-switch** widget:
|
||||||
|
|
||||||
|
- One row per configured antenna, with a **Port A** and **Port B** button.
|
||||||
|
- Colours: green = selected on port A, blue = selected on port B, red (pulsing) =
|
||||||
|
that port is transmitting. Clicking a selected port deselects it (→ None).
|
||||||
|
- **Band filter** (funnel icon in the header): shows only the antennas configured
|
||||||
|
for the current band, like the native app. Toggle it off to see all antennas.
|
||||||
|
Antennas with an unknown band mask are always shown.
|
||||||
|
|
||||||
|
Configure the host / password in Settings → Antenna Genius.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Audio and Keyers
|
||||||
|
|
||||||
|
## CW keyer
|
||||||
|
|
||||||
|
A CW keyer with **macros** and F-key macros. The keyer **engine** is selectable
|
||||||
|
(Settings → CW Keyer):
|
||||||
|
|
||||||
|
- **WinKeyer** — K1EL WK1/2/3 over a COM port.
|
||||||
|
- **Icom** — the radio's own keyer over CI-V, no extra hardware. Works over the
|
||||||
|
remote link too. (Requires the Icom CAT backend; set break-in to SEMI/FULL or
|
||||||
|
the rig keys the sidetone but stays in RX.)
|
||||||
|
- **TCI** — for TCI backends.
|
||||||
|
|
||||||
|
## Digital Voice Keyer (DVK)
|
||||||
|
|
||||||
|
Record **F1–F6** voice messages and transmit them. Set the audio devices, PTT
|
||||||
|
method and gains in Settings → Audio.
|
||||||
|
|
||||||
|
- **PTT** method: CAT, RTS, DTR, or none (VOX).
|
||||||
|
- **Test PTT** keys the rig briefly. On CAT/OmniRig the key is held ~1.5 s so the
|
||||||
|
transmit command is actually sent (OmniRig is asynchronous and coalesces rapid
|
||||||
|
writes).
|
||||||
|
|
||||||
|
## QSO audio recording
|
||||||
|
|
||||||
|
Continuous rolling capture. On **Log QSO** the contact is saved to a per-QSO WAV
|
||||||
|
(`CALL_YYYYMMDD_HHMMSS.wav`), mixing RX + mic. Configure the recordings folder,
|
||||||
|
format (WAV / MP3), pre-roll and gains in Settings → Audio.
|
||||||
|
|
||||||
|
## RX audio monitor (USB rigs)
|
||||||
|
|
||||||
|
Settings → Audio → **Listen to radio** pipes the rig's received audio (its "USB
|
||||||
|
Audio CODEC" input) through your speakers, so you hear the radio inside OpsLog.
|
||||||
|
**Talk to radio** keys PTT and pipes your mic to the rig's audio input. (Network
|
||||||
|
audio for remote Icom is a work in progress — see
|
||||||
|
[[Remote Icom over the Internet]].)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Awards
|
||||||
|
|
||||||
|
OpsLog has a flexible awards engine. Award **definitions** and reference lists are
|
||||||
|
shared **globally** across profiles.
|
||||||
|
|
||||||
|
## Built-in awards
|
||||||
|
|
||||||
|
DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF, **DDFM** (French
|
||||||
|
departments), and more — tracked **worked / confirmed / validated** by band and
|
||||||
|
mode.
|
||||||
|
|
||||||
|
## How matching works
|
||||||
|
|
||||||
|
An award scans a **QSO field** (state, QTH, address, notes, …) for a reference,
|
||||||
|
using one **Match by** mode:
|
||||||
|
|
||||||
|
- **code** — the field value *is* the reference (e.g. state = `NY`).
|
||||||
|
- **description** — the reference's **name** appears in the field (substring,
|
||||||
|
space-sensitive — "Hongkong" won't match "Hong Kong").
|
||||||
|
- **pattern** — the field is tested against a regex.
|
||||||
|
|
||||||
|
Plus a **leading/trailing** strip, a **prefix** prepended to found references, a
|
||||||
|
**dynamic** mode (any value counts, like POTA), and the **fallback searches**
|
||||||
|
described below.
|
||||||
|
|
||||||
|
### The five rules to remember
|
||||||
|
|
||||||
|
1. **Every regex is case-insensitive.** Award and per-reference patterns match
|
||||||
|
regardless of case (log fields are typed however the operator felt) — so
|
||||||
|
`\bTok[iy]o\b` catches `TOKYO`, `Tokyo` and even `TOKIO`. Set your own flag
|
||||||
|
group (e.g. `(?s)`) to override.
|
||||||
|
|
||||||
|
2. **A reference's own `Pattern` is opt-in extra recognition.** On the
|
||||||
|
*References* tab each reference has a **Pattern (regex)** field. Fill it to
|
||||||
|
recognise a reference by more than its name — spelling variants, or the
|
||||||
|
**cities/terms** that imply it. Leave it empty and the reference is matched by
|
||||||
|
name only. Example — Jiangsu (China) carries the cities in its own regex:
|
||||||
|
`\b(Jiangyin|Wuxi|Suzhou|Nanjing|Changzhou)\b`.
|
||||||
|
|
||||||
|
3. **`description` also runs the per-reference regexes** (on the same field).
|
||||||
|
A description search looks for the reference *name* **OR** fires each
|
||||||
|
reference's own Pattern — so a description award over the QTH/address will
|
||||||
|
auto-match your city regexes with no extra rule. (A reference with an empty
|
||||||
|
Pattern stays name-only.)
|
||||||
|
|
||||||
|
4. **OR rules are ordered fallbacks (first hit wins).** The **Fallback searches**
|
||||||
|
section adds extra searches tried **in order, only while nothing has matched
|
||||||
|
yet** — the first that finds a reference wins and the rest are skipped
|
||||||
|
(short-circuit, like a chain of *else-if*). So a province already resolved by
|
||||||
|
name isn't also re-tagged, possibly differently, by a later city regex.
|
||||||
|
|
||||||
|
5. **Scope is checked first — a manual reference does not bypass it.** DXCC
|
||||||
|
filter, valid bands / modes / emission and the valid-from/to dates gate every
|
||||||
|
QSO before any reference is looked at. A 17 m QSO can't count for an award
|
||||||
|
whose valid bands are 80/40/20/15/10 m, even if you assign the reference by
|
||||||
|
hand. Widen the award's bands if it *should* count.
|
||||||
|
|
||||||
|
> **Tip — Hong Kong / Macau style mismatches:** if the log says "Hong Kong" but
|
||||||
|
> the reference name is "Hongkong", either fix the name, or give that reference a
|
||||||
|
> **Pattern** like `\bHong ?Kong\b`. Thanks to rule 3 the pattern now works even
|
||||||
|
> for a *description* award — no separate rule needed.
|
||||||
|
|
||||||
|
### Worked example — Worked All Provinces of China (WAPC)
|
||||||
|
|
||||||
|
A custom province award where the log rarely spells the province out (it names a
|
||||||
|
*city*). The clean setup:
|
||||||
|
|
||||||
|
- **Primary:** *Search in* **QTH**, *Match by* **description**.
|
||||||
|
- **Fallback search:** *Search in* **address**, *Match by* **description**.
|
||||||
|
- On the *References* tab, give each province a Pattern listing its cities.
|
||||||
|
|
||||||
|
Now a QSO is resolved by the province **name** if present, else by a **city**
|
||||||
|
regex — first in the QTH, then in the address. First hit wins.
|
||||||
|
|
||||||
|
## Live detection & manual refs
|
||||||
|
|
||||||
|
- References are detected **live** as you enter a callsign.
|
||||||
|
- You can **manually assign** a reference to a QSO (the *Award Refs* tab of the
|
||||||
|
QSO editor). It's stored in an ADIF extra (`APP_OPSLOG_AWARDREFS`) so it
|
||||||
|
survives export/import — see [[Import and Export ADIF]] — and is honoured
|
||||||
|
everywhere (award panel, grid columns, totals). For a list-backed award the
|
||||||
|
assigned reference must still be a **valid, listed** reference, and the QSO
|
||||||
|
must be **in scope** (rule 5) for it to count.
|
||||||
|
|
||||||
|
## Reference lists & display
|
||||||
|
|
||||||
|
- **Import reference lists** for totals and names.
|
||||||
|
- Per award, choose what the Recent-QSOs column shows: **reference**, **name**,
|
||||||
|
or **both**. Award columns are opt-in per the Columns picker
|
||||||
|
([[Recent QSOs and Filters]]).
|
||||||
|
|
||||||
|
## Rescan
|
||||||
|
|
||||||
|
**Rescan** re-pulls the logbook and recomputes — it picks up fresh LoTW / QRZ /
|
||||||
|
eQSL confirmations (see [[QSL Management]]).
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# CAT Control
|
||||||
|
|
||||||
|
OpsLog has **four native CAT backends** (Settings → CAT). Each auto-reconnects
|
||||||
|
and connects *non-blocking*, so a powered-off radio never freezes the app.
|
||||||
|
|
||||||
|
| Backend | Use it for |
|
||||||
|
|---------|-----------|
|
||||||
|
| **OmniRig** | Any OmniRig-supported rig (Rig 1 / Rig 2, hot-swap). |
|
||||||
|
| **FlexRadio (SmartSDR)** | FlexRadio 6000/8000 over the TCP API. → [[FlexRadio]] |
|
||||||
|
| **Icom CI-V** | Any Icom over **USB** *or* over the internet via its **LAN server**. → [[Icom]] · [[Remote Icom over the Internet]] |
|
||||||
|
| **TCI** | SunSDR / ExpertSDR2 and any TCI (WebSocket) server. |
|
||||||
|
|
||||||
|
Once connected, frequency / band / mode follow the radio, clicking a cluster spot
|
||||||
|
tunes the rig, and (for Flex) cluster spots can be pushed to the panadapter.
|
||||||
|
|
||||||
|
## OmniRig
|
||||||
|
|
||||||
|
Pick **Rig 1** or **Rig 2** (the two OmniRig slots). Configure the actual COM
|
||||||
|
port / baud in OmniRig's own settings. Works with anything OmniRig supports; PTT
|
||||||
|
keying via CAT is supported where the rig's OmniRig profile exposes it.
|
||||||
|
|
||||||
|
## FlexRadio
|
||||||
|
|
||||||
|
Enter the radio's IP (UDP discovery assists). You get a full SmartSDR-style
|
||||||
|
control tab — see **[[FlexRadio]]**.
|
||||||
|
|
||||||
|
## Icom CI-V (USB)
|
||||||
|
|
||||||
|
- **Rig model** — pick your model from the dropdown; it sets the correct **CI-V
|
||||||
|
address** automatically (or choose *Other* and type the hex address).
|
||||||
|
- **COM port** + **baud** must match the radio (set *CI-V USB Echo Back* **OFF**
|
||||||
|
on the rig).
|
||||||
|
- The model choice also tailors the console (e.g. attenuator steps: 20 dB on an
|
||||||
|
IC-7300, 6/12/18 dB on an IC-7610).
|
||||||
|
|
||||||
|
Full console details: **[[Icom]]**.
|
||||||
|
|
||||||
|
## TCI
|
||||||
|
|
||||||
|
Enter the TCI host + port (default 40001). Gives freq / mode / PTT / split and,
|
||||||
|
optionally, panorama spots.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The **digital sub-mode** (FT4 vs FT8) is inferred from the frequency.
|
||||||
|
- **Per-band Flex RX/TX antennas** can be configured and are applied
|
||||||
|
automatically on band change (Settings → FlexRadio).
|
||||||
|
- Split, RIT/XIT and other rig-specific features are exposed on each backend's
|
||||||
|
control tab where supported.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Contest Logging
|
||||||
|
|
||||||
|
**Contest tab.** Pick a contest and an exchange, and OpsLog handles the rest.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
- **Contest** — from the built-in ADIF `CONTEST_ID` list.
|
||||||
|
- **Exchange** — a **running serial** (auto-incrementing) or a **fixed exchange**.
|
||||||
|
- **Window** — start / end times; QSOs outside are flagged.
|
||||||
|
|
||||||
|
## While operating
|
||||||
|
|
||||||
|
- OpsLog auto-fills `CONTEST_ID` and the **sent / received serials** (`STX` /
|
||||||
|
`SRX`).
|
||||||
|
- **Dupe** detection flags a call already worked in the contest.
|
||||||
|
- A live **scoreboard** tracks your progress.
|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
Export the contest log to **Cabrillo** for submission (Recent QSOs → export), or
|
||||||
|
to ADIF. See [[Import and Export ADIF]].
|
||||||
|
|
||||||
|
## Tip
|
||||||
|
|
||||||
|
Use **Bulk edit field** (right-click in Recent QSOs) to fix a contest field
|
||||||
|
across many QSOs at once — e.g. set `CONTEST_ID` or an exchange string on an
|
||||||
|
imported batch.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# DX Cluster and Spots
|
||||||
|
|
||||||
|
## Servers
|
||||||
|
|
||||||
|
Add multiple cluster servers (Settings). They auto-reconnect; one is the
|
||||||
|
**master** used for sending commands. The Cluster tab and the Main-view cluster
|
||||||
|
pane share the same live feed.
|
||||||
|
|
||||||
|
## Filter sidebar
|
||||||
|
|
||||||
|
A show/hide **filter sidebar** (shared by the Cluster tab and the Main pane):
|
||||||
|
|
||||||
|
- **Callsign** search, **hide worked**, **group duplicates**.
|
||||||
|
- Filter by **band / mode / status / source**.
|
||||||
|
|
||||||
|
## Per-spot status and tuning
|
||||||
|
|
||||||
|
Each spot is coloured by **status** relative to your log:
|
||||||
|
|
||||||
|
- **new** (entity never worked), **new-band**, **new-slot**, **worked**.
|
||||||
|
|
||||||
|
Click a spot to **tune the rig** (fills the callsign into the entry strip). A
|
||||||
|
multi-band **Band Map** shows panadapter-style strips per band.
|
||||||
|
|
||||||
|
**POTA** spots are tagged with their park reference (via `api.pota.app`).
|
||||||
|
|
||||||
|
## Sending a spot
|
||||||
|
|
||||||
|
Use the **Send Spot** window to announce a DX spot on the master cluster:
|
||||||
|
callsign + frequency + a message. The frequency defaults to the **live CAT
|
||||||
|
frequency** (full resolution, including sub-kHz).
|
||||||
|
|
||||||
|
## Spot alerts
|
||||||
|
|
||||||
|
**Tools → Alert management** — Log4OM-style alert rules on **call / country /
|
||||||
|
band / mode / spotter**, with **sound**, **visual** and **e-mail** notification.
|
||||||
|
Active alerts appear as a discreet bell in the entry strip; click it for the
|
||||||
|
list. Alerts are kept briefly and only shown when active.
|
||||||
|
|
||||||
|
See also: [[Maps and Antennas]] for turning the beam to a spot.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# FlexRadio
|
||||||
|
|
||||||
|
When the CAT backend is a **FlexRadio (SmartSDR)**, OpsLog shows a full
|
||||||
|
SmartSDR-style control panel (as a Main-view pane or the FlexRadio tab). It talks
|
||||||
|
the radio's TCP API directly and streams live meters over UDP (VITA-49).
|
||||||
|
|
||||||
|
## Slices (multi-slice)
|
||||||
|
|
||||||
|
FlexRadios can run several receiver slices (A/B/C/D…). OpsLog shows **every
|
||||||
|
in-use slice** as a row of buttons under the header:
|
||||||
|
|
||||||
|
- Click a slice to make it the **active** slice — the main frequency, mode, DSP
|
||||||
|
and spot-clicks all follow it.
|
||||||
|
- Click a slice's **TX** badge to move the transmitter onto it.
|
||||||
|
- **Split** is detected only when the TX slice and a same-band RX slice form a
|
||||||
|
genuine split; independent slices on other bands are not shown as split.
|
||||||
|
|
||||||
|
Everything you do targets the **active** slice, so a second slice monitoring
|
||||||
|
another band never hijacks your main frequency.
|
||||||
|
|
||||||
|
## Header
|
||||||
|
|
||||||
|
Model + RX/TX state, plus compact **PA voltage** and **PA temperature** readouts
|
||||||
|
(temperature turns amber/red as it climbs).
|
||||||
|
|
||||||
|
## Transmit
|
||||||
|
|
||||||
|
RF power, tune power, **TUNE**, **MOX**, **SPLIT**, speech processor (NOR / DX /
|
||||||
|
DX+), VOX (+ level + delay), monitor (+ level), mic gain, **TX filter** low/high
|
||||||
|
cut, and the **mic profile** selector. On CW the panel switches to keyer controls
|
||||||
|
(speed / pitch / break-in / sidetone / filter).
|
||||||
|
|
||||||
|
## Receive (active slice)
|
||||||
|
|
||||||
|
RX/TX antenna, AGC mode + threshold, audio level, and the noise/notch filters:
|
||||||
|
**NB**, **WNB** (wideband noise blanker), **NR**, **ANF**, and **APF** on CW —
|
||||||
|
each a toggle plus level. Filter-width presets per mode.
|
||||||
|
|
||||||
|
## Amplifier (PowerGenius XL)
|
||||||
|
|
||||||
|
If a PowerGenius XL is detected, its **OPERATE / STANDBY**, **fan mode** and
|
||||||
|
**fault** show in an Amplifier card, with the amp's FWD / ID / TEMP meters
|
||||||
|
inline. See [[Amplifiers and Switches]].
|
||||||
|
|
||||||
|
## Meters
|
||||||
|
|
||||||
|
Live UDP meters under the slices: **S-meter** (S-units, click → fill RST),
|
||||||
|
**forward power** (W), **SWR**, **mic** level (−40…+10 dB, red = overdrive) and
|
||||||
|
**compression**. PA voltage and temperature are in the header.
|
||||||
|
|
||||||
|
## Antennas per band
|
||||||
|
|
||||||
|
Configure Flex **RX/TX antennas per band** (Settings → FlexRadio); OpsLog applies
|
||||||
|
them automatically on band change.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Getting Started
|
||||||
|
|
||||||
|
This walks you from a fresh install to your first logged QSO.
|
||||||
|
|
||||||
|
## 1. Set your station
|
||||||
|
|
||||||
|
**Settings → Station**: enter your **callsign**, **grid locator** and **name**.
|
||||||
|
These feed callsign lookups, the map, awards, the QSL card and the "my station"
|
||||||
|
ADIF fields on every QSO.
|
||||||
|
|
||||||
|
## 2. (Optional) Connect a radio
|
||||||
|
|
||||||
|
**Settings → CAT**, pick a backend and fill its fields — see [[CAT Control]].
|
||||||
|
Once connected, the frequency, band and mode fill in automatically as you tune,
|
||||||
|
and clicking a cluster spot tunes the rig.
|
||||||
|
|
||||||
|
You can log perfectly well **without** a radio — just type the band, mode and
|
||||||
|
frequency.
|
||||||
|
|
||||||
|
## 3. The entry strip
|
||||||
|
|
||||||
|
The strip across the top is the heart of OpsLog (Log4OM-style):
|
||||||
|
|
||||||
|
- **Callsign** — type it and the contacted entity's **flag** appears next to the
|
||||||
|
RST fields; the country, CQ/ITU zones and continent resolve offline from
|
||||||
|
`cty.dat`. If QRZ/HamQTH lookup is configured, name / QTH / grid auto-fill and
|
||||||
|
a photo shows in the F2 tab.
|
||||||
|
- **RST sent / rcvd**, **Name**, **QTH**, **Grid**.
|
||||||
|
- **Band / Mode / Frequency** (TX and, for split, RX).
|
||||||
|
- **Start / End time**, **Comment / Note**.
|
||||||
|
|
||||||
|
Press **Enter** in any text field to **log** the QSO. The contact drops into
|
||||||
|
**Recent QSOs** below.
|
||||||
|
|
||||||
|
> Mode comes from the radio; the digital sub-mode (FT4 vs FT8) is inferred from
|
||||||
|
> the frequency.
|
||||||
|
|
||||||
|
## 4. Look around
|
||||||
|
|
||||||
|
- **Recent QSOs** — your log; double-click a row to edit every field.
|
||||||
|
See [[Recent QSOs and Filters]].
|
||||||
|
- **Worked-before matrix** — as you type a call, see if/when you worked that
|
||||||
|
entity, per band/mode slot.
|
||||||
|
- **Main view** — two configurable panes (Settings → General → *Main view*): a
|
||||||
|
great-circle map, the cluster, the FlexRadio or Icom console, Net control, and
|
||||||
|
more. See [[Maps and Antennas]].
|
||||||
|
|
||||||
|
## 5. Profiles
|
||||||
|
|
||||||
|
Every setting is **per-profile**. A profile can log to the local SQLite file or
|
||||||
|
a shared **MySQL** database (multi-operator). Create one per call / station /
|
||||||
|
event. See [[Profiles and Databases]].
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
- Chase spots → [[DX Cluster and Spots]]
|
||||||
|
- Track awards → [[Awards]]
|
||||||
|
- Send confirmations → [[QSL Management]]
|
||||||
|
- Design a card → [[QSL Card Designer]]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# OpsLog
|
||||||
|
|
||||||
|
A modern, fast ham-radio logger for **Windows** — Log4OM-style entry, real-time
|
||||||
|
CAT for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB
|
||||||
|
*and* remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert
|
||||||
|
Electronics), a DX cluster with spot alerts, awards tracking, maps, contest
|
||||||
|
logging, QSL management and a QSL-card designer.
|
||||||
|
|
||||||
|
Built with **Wails v2** (Go backend + React/TypeScript frontend), **pure Go**
|
||||||
|
(no CGO). SQLite holds the configuration; the logbook can be the local SQLite
|
||||||
|
file *or* a **shared MySQL** database so several operators run one log. Fully
|
||||||
|
themeable and bilingual (English / French).
|
||||||
|
|
||||||
|
Developed by **F4BPO**.
|
||||||
|
|
||||||
|
## New here? Read these first
|
||||||
|
|
||||||
|
1. **[[Installation]]** — download / build and first launch.
|
||||||
|
2. **[[Getting Started]]** — the entry strip, logging your first QSO, choosing a
|
||||||
|
profile.
|
||||||
|
3. **[[CAT Control]]** — connect your radio (OmniRig, FlexRadio, Icom, TCI).
|
||||||
|
|
||||||
|
## What OpsLog does
|
||||||
|
|
||||||
|
| Area | Pages |
|
||||||
|
|------|-------|
|
||||||
|
| Enter and manage QSOs | [[Logging Basics]] · [[Recent QSOs and Filters]] · [[Import and Export ADIF]] |
|
||||||
|
| Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] |
|
||||||
|
| Chase DX | [[DX Cluster and Spots]] · [[Maps and Antennas]] |
|
||||||
|
| Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] |
|
||||||
|
| Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] |
|
||||||
|
| Confirmations | [[Awards]] · [[QSL Management]] · [[QSL Card Designer]] |
|
||||||
|
| Configuration | [[Settings and Data]] · [[Security]] · [[Profiles and Databases]] |
|
||||||
|
| Something wrong? | [[Troubleshooting]] |
|
||||||
|
|
||||||
|
## Design in one line
|
||||||
|
|
||||||
|
Everything you configure (settings, profiles, rigs, cluster nodes, award lists,
|
||||||
|
QSL templates, lookup cache) lives in a **local SQLite** file so the UI is
|
||||||
|
instant. Only the **QSOs** live where the active profile points them — local
|
||||||
|
SQLite or a shared MySQL. See [[Profiles and Databases]].
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Icom
|
||||||
|
|
||||||
|
When the CAT backend is **Icom CI-V** (USB or network), OpsLog shows a full
|
||||||
|
RS-BA1-style console — the same panel whether you're on USB or remote.
|
||||||
|
|
||||||
|
For setup, see [[CAT Control]]; for the internet link, see
|
||||||
|
[[Remote Icom over the Internet]].
|
||||||
|
|
||||||
|
## Choosing your model
|
||||||
|
|
||||||
|
Pick your model from the **Rig model** dropdown in Settings → CAT — it sets the
|
||||||
|
CI-V address and tailors the console to the radio (e.g. attenuator steps).
|
||||||
|
The model is also auto-detected from the radio's CI-V ID.
|
||||||
|
|
||||||
|
## Console
|
||||||
|
|
||||||
|
- **Twin VFO readout** (MAIN / SUB): big tabular frequency, mode badge, band, and
|
||||||
|
RIT/ΔTX offset, with a **mode-button row** (SSB / CW / RTTY / PSK / AM / FM).
|
||||||
|
- **Spectrum scope + waterfall**: ON/OFF, CTR/FIX, double-click to tune, and
|
||||||
|
**◀ ⊙ ▶** to centre the scope on the current frequency (±50 kHz) and pan.
|
||||||
|
- **Live meters** always visible: S-meter (click → fill RST), power in watts, SWR.
|
||||||
|
- **Receive DSP:** AF / RF gain, squelch, AGC, preamp, **attenuator** (steps match
|
||||||
|
the model — e.g. 20 dB on an IC-7300, 6/12/18 dB on an IC-7610), filter
|
||||||
|
(FIL1/2/3), NB, NR, ANF, and — **on CW only** — the **APF** (audio peak filter).
|
||||||
|
- **Passband / notch:** Twin PBT (inner / outer) and manual notch + position.
|
||||||
|
- **Transmit:** RF power, MOX, TUNE, **split with automatic offset** (+5 kHz SSB /
|
||||||
|
+1 kHz CW), monitor. On **voice modes only**: mic gain, compressor, VOX (+ gain
|
||||||
|
+ anti-VOX). Controls that don't apply to the current mode are hidden.
|
||||||
|
- **Bands & antenna:** one-touch band buttons, ANT1/ANT2.
|
||||||
|
- **Clarifiers:** RIT and ΔTX, wheel / ± tuning (Ctrl + ← / → nudges RIT).
|
||||||
|
|
||||||
|
## Power ON / OFF
|
||||||
|
|
||||||
|
- **Network:** ON and OFF buttons are shown — the radio's LAN server answers even
|
||||||
|
in standby, so ON works.
|
||||||
|
- **USB:** the buttons are **hidden**. Over USB the CI-V interface is unpowered
|
||||||
|
while the rig is off, so a power-ON command can't reach it — turn the radio on
|
||||||
|
physically.
|
||||||
|
|
||||||
|
The app never wakes the rig on connect (manual by design).
|
||||||
|
|
||||||
|
## CW keying
|
||||||
|
|
||||||
|
CW can run through the **radio's own keyer** over CI-V — no extra hardware, and
|
||||||
|
it works over the remote link too. See [[Audio and Keyers]].
|
||||||
|
|
||||||
|
## Model-specific notes
|
||||||
|
|
||||||
|
- **IC-7300 / IC-705 / IC-9100:** single-receiver, single-step attenuator
|
||||||
|
(20 dB); the IC-9100 is USB-only (no LAN server).
|
||||||
|
- **IC-7610 / IC-9700:** dual scope (main/sub); stepped attenuator; LAN server
|
||||||
|
for remote.
|
||||||
|
- If the **scope is blank on a single-scope rig run at a non-default CI-V
|
||||||
|
address**, OpsLog now detects the model from the CI-V ID, not the configured
|
||||||
|
address, so it decodes single-scope frames correctly.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Import and Export ADIF
|
||||||
|
|
||||||
|
OpsLog is **ADIF 3.1.7 compliant**: a full field dictionary, 30 promoted columns,
|
||||||
|
a generic "extra fields" editor for anything not promoted, and standard/all
|
||||||
|
export modes.
|
||||||
|
|
||||||
|
## Import
|
||||||
|
|
||||||
|
Import an `.adi` / `.adif` file into the active profile's logbook. On import,
|
||||||
|
OpsLog parses every field, maps standard fields to columns and keeps unknown but
|
||||||
|
valid ADIF fields in a per-QSO **extras** store (editable later). Country / zones
|
||||||
|
are enriched from `cty.dat` where missing.
|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
- **Whole log**, **selected rows**, or the **filtered view** (no limit) — see
|
||||||
|
[[Recent QSOs and Filters]].
|
||||||
|
- **Standard** mode exports the common promoted fields; **All** mode also exports
|
||||||
|
every extra field.
|
||||||
|
- **Cabrillo** export for contest submission.
|
||||||
|
|
||||||
|
## Round-trip safety
|
||||||
|
|
||||||
|
Award references you assign manually are stored in an ADIF extra field
|
||||||
|
(`APP_OPSLOG_AWARDREFS`), so exporting your full log and re-importing it does not
|
||||||
|
lose your award-reference assignments.
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- To move a batch of QSOs between logs, export selected → import into the other
|
||||||
|
profile.
|
||||||
|
- Use **Find duplicates** (Tools) after importing to catch overlaps.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
OpsLog is a single Windows executable — no installer, no runtime to add. Pure Go
|
||||||
|
+ an embedded WebView2 (present on Windows 10/11 by default).
|
||||||
|
|
||||||
|
## Download and run
|
||||||
|
|
||||||
|
1. Grab the latest `OpsLog.exe` from the project's **Releases** page.
|
||||||
|
2. Put it in its own folder (it will create a `data/` folder next to itself).
|
||||||
|
3. Double-click to run. On first launch you pick a **language** (English /
|
||||||
|
French) and OpsLog creates its local database.
|
||||||
|
|
||||||
|
> **Where is my data?** A `data/` folder is created next to the executable,
|
||||||
|
> holding the SQLite config + local logbook, QSL templates, recordings and
|
||||||
|
> backups. Keep the exe and `data/` together, or back up `data/`. See
|
||||||
|
> [[Settings and Data]].
|
||||||
|
|
||||||
|
## Building from source (developers)
|
||||||
|
|
||||||
|
OpsLog is built with **Wails v2.11**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dev — Vite hot-reload; Go methods reachable at http://localhost:34115
|
||||||
|
wails dev
|
||||||
|
|
||||||
|
# Production build → build/bin/OpsLog.exe
|
||||||
|
wails build
|
||||||
|
|
||||||
|
# Regenerate Go↔TS bindings after changing exported App methods
|
||||||
|
wails generate module
|
||||||
|
```
|
||||||
|
|
||||||
|
Prerequisites: Go, Node.js, and the Wails CLI (`~/go/bin/wails.exe`). No CGO /
|
||||||
|
C toolchain is needed.
|
||||||
|
|
||||||
|
## First-run checklist
|
||||||
|
|
||||||
|
- **Settings → General** — language, theme, and the two Main-view panes.
|
||||||
|
- **Settings → Station** — your callsign, grid, name (used for lookups, awards,
|
||||||
|
QSL cards and the map).
|
||||||
|
- **Settings → CAT** — connect your radio ([[CAT Control]]).
|
||||||
|
- **Settings → Lookup** — QRZ.com / HamQTH credentials for callsign lookup.
|
||||||
|
|
||||||
|
Continue with **[[Getting Started]]**.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Logging Basics
|
||||||
|
|
||||||
|
## The entry strip
|
||||||
|
|
||||||
|
The entry strip logs a QSO from any `<input>` inside it — press **Enter** to
|
||||||
|
save. Fields: callsign, RST tx/rx, name, QTH, grid, band, mode, TX/RX frequency
|
||||||
|
(split), start/end time, comment and note.
|
||||||
|
|
||||||
|
- **Callsign** drives everything: the **flag**, offline DXCC resolution (country,
|
||||||
|
CQ/ITU zones, continent) from `cty.dat` including `/MM` `/AM` `/B` and call-area
|
||||||
|
handling (`/8`, `/W6`), plus **ClubLog** DXpedition date overrides.
|
||||||
|
- **Callsign lookup** (QRZ.com / HamQTH) fills name / QTH / grid and shows the
|
||||||
|
photo and a QRZ.com tab. Configure it in Settings → Lookup.
|
||||||
|
- **ESC** clears the callsign (and stops CW).
|
||||||
|
|
||||||
|
## Editing a QSO
|
||||||
|
|
||||||
|
Double-click any row in **Recent QSOs** (or Worked-before) to open the full
|
||||||
|
editor — every ADIF field across tabs: QSO info, contacted station's details,
|
||||||
|
award refs, QSL info, contest, sat/prop, my station, and a generic **extra
|
||||||
|
fields** editor for any ADIF field not promoted to a column.
|
||||||
|
|
||||||
|
## Bulk operations
|
||||||
|
|
||||||
|
Select rows in Recent QSOs, right-click:
|
||||||
|
|
||||||
|
- **Fix country & zones** from `cty.dat`, or **update from QRZ / ClubLog**.
|
||||||
|
- **Bulk edit field** — set one field across many QSOs (QSL/upload status, my
|
||||||
|
station, contest, propagation, contacted-station refs, comment…). Great for
|
||||||
|
flipping a batch to *Requested* before an upload.
|
||||||
|
- **Send to** a QSL service, **export** selected / filtered to ADIF or Cabrillo,
|
||||||
|
**delete**.
|
||||||
|
|
||||||
|
## Find duplicates
|
||||||
|
|
||||||
|
**Tools → Find duplicates** groups QSOs by same **call + band + mode** (optionally
|
||||||
|
same day or same minute) and lets you pick which to delete.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Worked-before matrix** shows prior contacts with the entity per band/mode
|
||||||
|
slot as you type.
|
||||||
|
- Awards references are detected **live** as you enter a call (see [[Awards]]).
|
||||||
|
|
||||||
|
See also: [[Recent QSOs and Filters]] · [[Import and Export ADIF]]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Maps and Antennas
|
||||||
|
|
||||||
|
## Main view
|
||||||
|
|
||||||
|
The Main view is **two configurable panes** (per profile, Settings → General →
|
||||||
|
*Main view*). Choose from: great-circle map, locator (street) map, the cluster
|
||||||
|
grid, the worked-before grid, recent QSOs, the **FlexRadio** controls, the
|
||||||
|
**Icom** console, or the **Net control** panel.
|
||||||
|
|
||||||
|
## Great-circle map
|
||||||
|
|
||||||
|
- Short/long-path **distance & azimuth** to the worked station.
|
||||||
|
- Selectable, **key-free** basemaps: Light / Voyager / Street / Satellite (all
|
||||||
|
labelled).
|
||||||
|
- The **antenna beam lobe(s)** are drawn from the rotor azimuth.
|
||||||
|
|
||||||
|
## Locator (street) map
|
||||||
|
|
||||||
|
An OSM street map centred on the contacted grid square — handy for local /
|
||||||
|
portable contacts. No API key.
|
||||||
|
|
||||||
|
## Rotor compass
|
||||||
|
|
||||||
|
An azimuthal-equidistant **click-to-turn** compass driven by **PstRotator**. The
|
||||||
|
current heading and target are shown; click a bearing to turn the rotor.
|
||||||
|
|
||||||
|
## Ultrabeam
|
||||||
|
|
||||||
|
Ultrabeam antennas are supported with three modes — **Normal**, **180° reverse**,
|
||||||
|
**Bidirectional**:
|
||||||
|
|
||||||
|
- The **radiating direction** is shown in green and the **mechanical boom** in
|
||||||
|
grey, on both the compass and the map, so you always know where the antenna
|
||||||
|
actually points.
|
||||||
|
- The antenna **follows the rig frequency** (band change → retune), and retunes
|
||||||
|
immediately when you click a spot.
|
||||||
|
|
||||||
|
Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch).
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user