14 Commits
27 changed files with 73480 additions and 398 deletions
+770 -118
View File
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"testing"
"hamlog/internal/award"
)
// The catalog is the channel through which a shipped award is both DELIVERED and
// CORRECTED. These tests pin the three rules that make that safe:
// - a new catalog award reaches an operator who already has awards stored;
// - a fixed one (higher Version) replaces the stored copy;
// - unless the operator has edited it, in which case their work wins.
func catalogDef(t *testing.T, code string) award.Def {
t.Helper()
for _, d := range award.Defaults() {
if d.Code == code {
return d
}
}
t.Fatalf("%s is not in the catalog", code)
return award.Def{}
}
func findDef(defs []award.Def, code string) (award.Def, bool) {
for _, d := range defs {
if d.Code == code {
return d, true
}
}
return award.Def{}, false
}
func TestMergeCatalogAddsMissingAward(t *testing.T) {
stored := []award.Def{{Code: "MYOWN", Name: "Mine", Valid: true}}
got, updated, changed := mergeCatalog(stored)
if !changed {
t.Fatal("merge reported no change, but every catalog award was missing")
}
if len(updated) != 0 {
t.Errorf("updated = %v, want none: an ADDED award is not an UPDATED one", updated)
}
if _, ok := findDef(got, "FFMA"); !ok {
t.Error("FFMA was not added — a newly shipped award never reaches an existing install")
}
if _, ok := findDef(got, "MYOWN"); !ok {
t.Error("the operator's own award was dropped by the merge")
}
}
func TestMergeCatalogUpdatesOlderVersion(t *testing.T) {
// The stored copy is an older revision of a shipped award, with a broken rule.
old := catalogDef(t, "FFMA")
old.Version = catalogDef(t, "FFMA").Version - 1
old.Field = "wrong"
old.Valid = false // the operator disabled it — a preference, not a definition
got, updated, changed := mergeCatalog([]award.Def{old})
if !changed || len(updated) == 0 {
t.Fatal("a higher catalog version did not replace the stored definition — a shipped award could never be FIXED")
}
d, _ := findDef(got, "FFMA")
if d.Field != "grid4" {
t.Errorf("FFMA field = %q, want the catalog's %q", d.Field, "grid4")
}
if d.Valid {
t.Error("the update re-enabled an award the operator had switched off; that is their choice to make, not ours")
}
}
func TestMergeCatalogSkipsUserEdited(t *testing.T) {
old := catalogDef(t, "FFMA")
old.Version = catalogDef(t, "FFMA").Version - 1
old.Field = "mine"
old.UserEdited = true
got, updated, _ := mergeCatalog([]award.Def{old})
if len(updated) != 0 {
t.Fatalf("updated = %v: an award the operator has edited must never be overwritten", updated)
}
if d, _ := findDef(got, "FFMA"); d.Field != "mine" {
t.Errorf("field = %q, want the operator's %q", d.Field, "mine")
}
}
func TestMarkUserEditedOnlyOnRealChange(t *testing.T) {
prev := []award.Def{
{Code: "A", Name: "A", Field: "state", Valid: true, Version: 2},
{Code: "B", Name: "B", Field: "cqz", Valid: true, Version: 2},
}
next := []award.Def{
{Code: "A", Name: "A", Field: "state", Valid: true}, // untouched
{Code: "B", Name: "B", Field: "county", Valid: true}, // changed
{Code: "C", Name: "C", Field: "note", Valid: true}, // brand new
}
markUserEdited(next, prev)
if next[0].UserEdited {
t.Error("A was flagged as edited although nothing about it changed — every save would freeze every award out of future updates")
}
if !next[1].UserEdited {
t.Error("B changed field and was not flagged; a catalog update would overwrite the operator's work")
}
if next[2].UserEdited {
t.Error("C is a brand-new award; there is no shipped version to protect it from")
}
// A save must not pretend to be a new shipped revision.
if next[0].Version != 2 || next[1].Version != 2 {
t.Errorf("versions = %d/%d, want both 2: saving is not shipping", next[0].Version, next[1].Version)
}
}
+34 -11
View File
@@ -1180,22 +1180,29 @@ export default function App() {
// Effective antenna heading(s): the rotor azimuth, transformed by the
// Ultrabeam pattern when one is active — reversed (180°) points opposite,
// 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[]>(() => {
if (!(rotatorHeading.enabled && rotatorHeading.ok)) return [];
const base = ((rotatorHeading.azimuth % 360) + 360) % 360;
if (rotorAz == null) return [];
if (ubStatus.enabled && ubStatus.connected) {
if (ubStatus.direction === 1) return [(base + 180) % 360];
if (ubStatus.direction === 2) return [base, (base + 180) % 360];
if (ubStatus.direction === 1) return [(rotorAz + 180) % 360];
if (ubStatus.direction === 2) return [rotorAz, (rotorAz + 180) % 360];
}
return [base];
}, [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
return [rotorAz];
}, [rotorAz, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
// Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
// show where the antenna physically points (boom) vs where it radiates when
// the Ultrabeam is reversed/bidirectional.
const boomHeading = useMemo<number | null>(() => (
rotatorHeading.enabled && rotatorHeading.ok ? ((rotatorHeading.azimuth % 360) + 360) % 360 : null
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
const boomHeading = rotorAz;
const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
if (!(ubStatus.enabled && ubStatus.connected)) return null;
return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
@@ -2990,6 +2997,18 @@ export default function App() {
</div>
);
// 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 = (
<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>
@@ -2998,8 +3017,10 @@ export default function App() {
className="font-mono"
value={freqFocused ? freqMhz : (freqMhz ? fmtFreqDots(freqMhz) : '')}
placeholder="14.250"
title={catState.connected ? t('field.freqTuneHint') : undefined}
onFocus={() => setFreqFocused(true)}
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); }}
/>
</div>
@@ -3011,8 +3032,10 @@ export default function App() {
tabIndex={-1}
value={freqFocused ? rxFreqMhz : (rxFreqMhz ? fmtFreqDots(rxFreqMhz) : '')}
placeholder="14.255"
title={catState.connected ? t('field.freqTuneHint') : undefined}
onFocus={() => setFreqFocused(true)}
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); }}
className={cn('font-mono', catState.split && 'bg-danger-muted/40 border-danger-border focus:bg-card')}
/>
@@ -3480,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 && (
<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">
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
</button>
+221 -30
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search, FolderOpen, ArrowUpCircle } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -19,6 +19,7 @@ import {
ListCountries, DXCCForCountry, DXCCName,
PopulateBuiltinReferences, HasBuiltinReferences,
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
} from '../../wailsjs/go/main/App';
// Above this many references the editor stops loading the whole list and
@@ -32,7 +33,7 @@ export type AwardDef = {
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
ref_display?: string; // grid column shows: ref | name | both
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
leading_str?: string; trailing_str?: string; dynamic?: boolean;
or_rules?: AwardOrRule[];
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
@@ -186,6 +187,15 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
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[] };
@@ -201,6 +211,57 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
}, [importPreview]);
const cur = defs[sel];
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
// ── Award tester: run the award's rules against a real QSO and show every step.
type Rejected = { candidate: string; reason: string };
type Step = {
rule: string; field: string; match_by?: string; exact?: boolean; pattern?: string;
field_value?: string; candidates?: string[]; kept?: string[]; rejected?: Rejected[];
skipped?: boolean; error?: string;
};
type Explanation = {
code: string; in_scope: boolean; scope_error?: string; predefined: boolean;
ref_count: number; steps: Step[]; manual?: string[]; result: string[];
};
type TestRow = { qso: any; explanation: Explanation };
const [testCall, setTestCall] = useState('');
const [testRows, setTestRows] = useState<TestRow[] | null>(null);
const [testErr, setTestErr] = useState('');
const [testing, setTesting] = useState(false);
const runTest = async () => {
if (!cur) return;
setTesting(true); setTestErr(''); setTestRows(null);
try {
const r = await ExplainAward(cur.code, testCall);
setTestRows((Array.isArray(r) ? r : []) as TestRow[]);
} catch (e: any) {
setTestErr(String(e?.message ?? e));
} finally {
setTesting(false);
}
};
// The tester reads the SAVED award, not the unsaved edits in this dialog — so say
// so, rather than let the operator test a rule they only think they applied.
useEffect(() => { setTestRows(null); setTestErr(''); }, [sel]);
// Several QSOs with the same station usually trace IDENTICALLY, and twenty copies
// of the same trace is noise. But they don't always: scope is judged per QSO
// (band, mode, date — FFMA's 1983 cut-off), a manual override lives ON a QSO, and
// two contacts can even hold different QTHs. That divergence is often the actual
// answer ("why does my 2019 QSO count and my 2024 one not?"), so we group by
// trace instead of dropping it: one card per distinct outcome, with its count.
const testGroups = useMemo(() => {
if (!testRows) return null;
const groups: { key: string; rows: TestRow[] }[] = [];
for (const row of testRows) {
const key = JSON.stringify(row.explanation);
const g = groups.find((x) => x.key === key);
if (g) g.rows.push(row);
else groups.push({ key, rows: [row] });
}
return groups;
}, [testRows]);
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
const toggleIn = (key: keyof AwardDef, v: string) => {
const arr = ((cur?.[key] as string[]) ?? []);
@@ -312,6 +373,10 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
// has been exported. That deserves to be visible at a glance, not
// discovered the hard way.
const onlyHere = !catalogCodes.includes((d.code ?? '').toUpperCase());
// A pending update is only reachable from the award's own banner, so
// the list has to say which award to open — otherwise the fix waits
// behind a click nobody knows to make.
const hasUpdate = updates.some((u) => (u.code ?? '').toUpperCase() === (d.code ?? '').toUpperCase());
return (
<button key={i} onClick={() => setSel(i)}
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
@@ -319,6 +384,11 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-success')} />
<span className="font-mono font-semibold shrink-0">{d.code}</span>
<span className="text-muted-foreground truncate">{d.name}</span>
{hasUpdate && (
<span className="ml-auto shrink-0" title={t('awed.updateAvailable')}>
<ArrowUpCircle className="size-3.5 text-info" />
</span>
)}
{onlyHere && (
<span className="ml-auto shrink-0 px-1 rounded border border-warning-border bg-warning-muted text-warning-muted-foreground text-[9px] font-semibold uppercase tracking-wide"
title={t('awed.onlyHereTip')}>
@@ -337,6 +407,37 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
{/* Right: tabbed editor for selected award */}
<div className="flex flex-col min-h-0 overflow-hidden">
{err && <div onClick={() => setErr('')} title={t('awed.clickToDismiss')} className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all cursor-pointer">{err}</div>}
{/* A fix shipped for an award this operator has customised. We did NOT
apply it — that would destroy their work — so we offer it, and say
plainly what accepting costs. */}
{cur && selUpdate && (
<div className="mx-4 mt-3 rounded border border-info/40 bg-info/10 px-3 py-2 text-xs">
<div className="flex items-center gap-2">
<ArrowUpCircle className="size-4 text-info shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium">{t('awed.updateAvailable')}</div>
<div className="text-muted-foreground">{t('awed.updateOverwrites')}</div>
</div>
<Button size="sm" className="h-7 px-2 text-[11px]"
onClick={async () => {
try {
await ApplyAwardUpdate(cur.code);
// The backend rewrote this award (and its references) — pull the
// new state back, or the editor would keep showing, and on the
// next Save re-persist, the definition we just replaced.
setDefs(((await GetAwardDefs()) ?? []) as any);
loadMeta();
loadUpdates();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}}>{t('awed.updateApply')}</Button>
<Button size="sm" variant="ghost" className="h-7 px-2 text-[11px]"
onClick={async () => {
try { await DismissAwardUpdate(cur.code); loadUpdates(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}}>{t('awed.updateKeepMine')}</Button>
</div>
</div>
)}
{!cur ? (
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">{t('awed.selectOrCreate')}</div>
) : (
@@ -346,6 +447,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<TabsTrigger value="type">{t('awed.tabType')}</TabsTrigger>
<TabsTrigger value="conf">{t('awed.tabConfirmation')}</TabsTrigger>
<TabsTrigger value="refs">{t('awed.tabReferences')}</TabsTrigger>
<TabsTrigger value="test">{t('awed.tabTest')}</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-auto p-4">
@@ -355,14 +457,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
<Input className="h-8 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>
{/* "Built-in" is what you tick before dropping an award into the
shipped catalog. Leave it off and "Reset to defaults" DELETES
the award on the user's machine — even though you shipped it.
Editing the JSON by hand to fix that is exactly what we're
avoiding here. */}
<label className="flex items-center gap-1.5 text-xs cursor-pointer" title={t('awed.builtinTip')}>
<Checkbox checked={!!cur.builtin} onCheckedChange={(c) => patch({ builtin: !!c })} /> {t('awed.builtin')}
</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>
@@ -407,7 +507,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
}</SelectContent>
</Select>
</Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> {t('awed.allowMultiple')}</label>
{/* No "allow multiple references" switch: a QSO always counts for
every reference its field holds (an n-fer POTA activation, a
VUCC grid-line contact). The old checkbox was read by nothing. */}
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> {t('awed.dynamicRefs')}</label>
<div className="border-t pt-2.5 mt-1 space-y-2.5">
@@ -494,8 +596,105 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
))}
</div>
</div>
<Field2 label={t('awed.grantCodes')}><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> {t('awed.exportCreditGranted')}</label>
{/* "Grant codes" and "export credit_granted" used to live here. No
ADIF export has ever written CREDIT_GRANTED, so both controls
did nothing at all. The stored values are kept (see award.Def);
put the controls back when the export is actually wired up. */}
</TabsContent>
{/* ── Test ──
Runs the SAVED award against a real QSO and shows every rule:
what it scanned, what it produced, and why each candidate was
rejected. An award that matches nothing used to be a black box. */}
<TabsContent value="test" className="mt-0 space-y-3">
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1">
<Label className="text-xs text-muted-foreground">{t('awed.testCallsign')}</Label>
<Input className="h-8 w-40 font-mono uppercase" value={testCall} placeholder="I2IFT"
onChange={(e) => setTestCall(e.target.value.toUpperCase())}
onKeyDown={(e) => { if (e.key === 'Enter') runTest(); }} />
</div>
<Button size="sm" className="h-8" onClick={runTest} disabled={testing || !testCall.trim()}>
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Search className="size-3.5 mr-1" />}
{t('awed.testRun')}
</Button>
<span className="text-[11px] text-muted-foreground pb-1">{t('awed.testSavedOnly')}</span>
</div>
{testErr && <div className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{testErr}</div>}
{testGroups?.map((g, ri) => {
const row = g.rows[0];
const ex = row.explanation;
const matched = (ex.result ?? []).length > 0;
const qsoLabel = (q: any) => `${String(q?.qso_date ?? '').slice(0, 10)} · ${q?.band} · ${q?.mode}`;
return (
<div key={ri} className="rounded border border-border overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 text-xs border-b border-border">
<span className="font-mono font-semibold">{row.qso?.callsign}</span>
<span className="text-muted-foreground font-mono">{qsoLabel(row.qso)}</span>
{g.rows.length > 1 && (
<span className="text-muted-foreground" title={g.rows.slice(1).map((r) => qsoLabel(r.qso)).join('\n')}>
{t('awed.testSameAs', { n: String(g.rows.length - 1) })}
</span>
)}
<span className={cn('ml-auto px-1.5 rounded text-[10px] font-semibold uppercase tracking-wide',
matched ? 'bg-success/15 text-success' : 'bg-muted-foreground/15 text-muted-foreground')}>
{matched ? (ex.result ?? []).join(', ') : t('awed.testNoMatch')}
</span>
</div>
{!ex.in_scope ? (
<div className="px-3 py-2 text-xs">
<span className="font-medium">{t('awed.testOutOfScope')}</span>{' '}
<span className="text-muted-foreground">{ex.scope_error}</span>
</div>
) : (
<div className="divide-y divide-border/50">
{(ex.steps ?? []).map((s, si) => (
<div key={si} className={cn('px-3 py-2 text-xs', s.skipped && 'opacity-50')}>
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-semibold uppercase text-[10px] tracking-wide">{s.rule}</span>
<span className="text-muted-foreground">
{s.field}{s.match_by ? ` / ${s.match_by}` : ''}{s.exact ? ` / ${t('awed.exact')}` : ''}
</span>
{s.skipped && <span className="text-muted-foreground italic"> {t('awed.testSkipped')}</span>}
{s.error && <span className="text-destructive"> {s.error}</span>}
</div>
{!s.skipped && !s.error && (
<div className="mt-1 space-y-0.5 pl-3 border-l-2 border-border">
<div>
<span className="text-muted-foreground">{t('awed.testFieldValue')}: </span>
{s.field_value
? <span className="font-mono">{s.field_value}</span>
: <span className="italic text-muted-foreground">{t('awed.testEmptyField')}</span>}
</div>
{(s.kept ?? []).map((k) => (
<div key={k} className="text-success font-mono"> {k}</div>
))}
{(s.rejected ?? []).map((r, i2) => (
<div key={i2} className="font-mono text-muted-foreground">
{r.candidate} <span className="font-sans"> {r.reason}</span>
</div>
))}
{!(s.kept ?? []).length && !(s.rejected ?? []).length && s.field_value && (
<div className="italic text-muted-foreground">{t('awed.testNoCandidate')}</div>
)}
</div>
)}
</div>
))}
{(ex.manual ?? []).length > 0 && (
<div className="px-3 py-2 text-xs">
<span className="font-semibold uppercase text-[10px] tracking-wide">{t('awed.testManual')}</span>{' '}
<span className="font-mono text-success">{(ex.manual ?? []).join(', ')}</span>
</div>
)}
</div>
)}
</div>
);
})}
</TabsContent>
{/* ── References ── */}
@@ -744,26 +943,18 @@ function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChan
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
</div>
<Field2 label={t('awed.description')}><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
<div className="grid grid-cols-2 gap-3">
{/* One per row: side by side, each half spends 120px of its width on
the label column and the input is left too narrow to read a group
name ("Basilicata" → "Basili"). */}
<Field2 label={t('awed.group')}><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
<Field2 label={t('awed.subgroup')}><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
</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={t('awed.patternRegex')}><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder={t('awed.perRefRegex')} /></Field2>
<div className="grid grid-cols-3 gap-3">
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">{t('awed.score')}</Label>
<Input type="number" className="h-8 font-mono w-full" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} />
</div>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">{t('awed.bonus')}</Label>
<Input type="number" className="h-8 font-mono w-full" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} />
</div>
<div className="flex flex-col gap-1 min-w-0">
<Label className="text-xs text-muted-foreground">{t('awed.grid')}</Label>
<Input className="h-8 font-mono w-full" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} />
</div>
</div>
{/* Score / Bonus were here. Nothing computes an award score, so both
boxes were pure decoration. The columns stay in the database — a
third-party list may carry the values — but they are not offered
for editing until something actually reads them. */}
<Field2 label={t('awed.grid')}><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> {t('awed.saveReference')}</Button></div>
</div>
)}
+88 -1
View File
@@ -6,6 +6,7 @@ import {
FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetWNB, FlexSetWNBLevel, FlexSetTXFilter, FlexSetMicProfile,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
@@ -25,6 +26,7 @@ type FlexState = {
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean;
rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[];
split: boolean; rx_freq_hz?: number; tx_freq_hz?: number;
rit: boolean; rit_freq: number; xit: boolean; xit_freq: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
wnb: boolean; wnb_level: number;
tx_filter_low: number; tx_filter_high: number; mic_profile?: string; mic_profiles?: string[];
@@ -44,6 +46,7 @@ const ZERO: FlexState = {
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false,
rit: false, rit_freq: 0, xit: false, xit_freq: 0,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
wnb: false, wnb_level: 0, tx_filter_low: 0, tx_filter_high: 0,
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
@@ -143,6 +146,67 @@ function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, slide
);
}
// OffsetRow — RIT / XIT: a switch plus one signed offset field you scrub with the
// wheel (or ± , or the arrow keys once focused; hold Ctrl/Shift for 100 Hz steps).
// Deliberately the same control as the Icom panel's ShiftRow, so the two rigs are
// driven identically.
//
// The offset is NOT cleared when the switch goes off: SmartSDR keeps it, so
// flipping RIT back on returns you exactly where you were, like the radio's own
// knob. Zeroing it is a separate, deliberate act — that is what the 0 button is for.
const OFFSET_MAX = 99999; // SmartSDR's limit
function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
label: string; on: boolean; onToggle: () => void; hz: number; onHz: (v: number) => void;
disabled?: boolean; title?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const step = useRef((_d: number) => {});
step.current = (d: number) => onHz(Math.max(-OFFSET_MAX, Math.min(OFFSET_MAX, (hz || 0) + d)));
// React's onWheel is passive, so preventDefault() there is ignored and the panel
// scrolls under the cursor instead of the value changing. Bind it natively.
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
step.current(e.deltaY < 0 ? mag : -mag);
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
const onKey = (e: React.KeyboardEvent) => {
const up = e.key === 'ArrowUp' || e.key === 'ArrowRight';
const dn = e.key === 'ArrowDown' || e.key === 'ArrowLeft';
if (!up && !dn) return;
e.preventDefault();
const mag = e.ctrlKey || e.shiftKey ? 100 : 10;
step.current(up ? mag : -mag);
};
return (
<div className="flex items-center gap-2" title={title}>
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent="cyan" />
<div ref={ref} tabIndex={disabled || !on ? -1 : 0} onKeyDown={onKey}
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none',
'focus:outline-none focus:ring-2 focus:ring-info/50',
on && !disabled ? 'border-border bg-muted/40 cursor-ns-resize' : 'border-border/60 bg-muted/20 opacity-60')}>
<button type="button" disabled={disabled || !on} onClick={() => step.current(-10)}
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground"></button>
<span className={cn('text-sm font-mono font-bold tabular-nums', on && hz ? 'text-info' : 'text-muted-foreground')}>
{hz > 0 ? '+' : hz < 0 ? '' : ''}{Math.abs(hz || 0)} Hz
</span>
<button type="button" disabled={disabled || !on} onClick={() => step.current(10)}
className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground disabled:hover:text-muted-foreground">+</button>
</div>
<button type="button" disabled={disabled || !hz} onClick={() => onHz(0)}
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted disabled:opacity-30">0</button>
</div>
);
}
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
// `display` overrides the numeric readout; `segColor` colours segments by their
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
@@ -454,6 +518,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
)}
</div>
{!isCW ? (
<div className="border-t border-border/60 pt-3 space-y-3">
<div className="flex items-center gap-2">
@@ -560,6 +625,18 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</div>
</div>
)}
{/* RIT / XIT — on the RECEIVE side: RIT is what you reach for while
listening (chasing a station that drifts off your transmit frequency),
so it belongs with the other things you touch mid-QSO. Both act on the
ACTIVE slice and follow slice focus like every control here. */}
<div className="space-y-1.5 pb-3 border-b border-border/60">
<OffsetRow label="RIT" on={st.rit} disabled={rxOff} hz={st.rit_freq} title={t('flxp.ritHint')}
onToggle={() => change('rit', !st.rit, () => FlexSetRIT(!st.rit))}
onHz={(v) => change('rit_freq', v, () => FlexSetRITFreq(v))} />
<OffsetRow label="XIT" on={st.xit} disabled={rxOff} hz={st.xit_freq} title={t('flxp.xitHint')}
onToggle={() => change('xit', !st.xit, () => FlexSetXIT(!st.xit))}
onHz={(v) => change('xit_freq', v, () => FlexSetXITFreq(v))} />
</div>
<div className="flex items-center gap-2">
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
@@ -701,7 +778,17 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
}
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={acc} />;
// Drain current (ID): the PGXL reports a full-scale far too small
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
// value itself is fine — only the bar's range was wrong. Give it a
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
// only override an unusable reported range, not a sane one.
let lo = m.lo, hi = m.hi;
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
lo = 0;
hi = m.hi >= 25 ? m.hi : 25;
}
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
})}
</div>
);
+39 -13
View File
@@ -64,6 +64,24 @@ function loadBasemap(): BasemapKey {
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
}
// addBasemap (re)installs the imagery layer and, for satellite, its transparent
// place-name overlay. updateWhenIdle/keepBuffer keep the number of live tiles
// down: satellite loads TWO tile layers, so its tile count — and the composited
// layers WebView2 has to hold — is double every other basemap's.
function addBasemap(
m: L.Map,
key: BasemapKey,
base: React.MutableRefObject<L.TileLayer | null>,
labels: React.MutableRefObject<L.TileLayer | null>,
) {
if (base.current) { m.removeLayer(base.current); base.current = null; }
if (labels.current) { m.removeLayer(labels.current); labels.current = null; }
const bm = BASEMAPS[key];
const opts: L.TileLayerOptions = { maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 1 };
base.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
if (bm.labelsUrl) labels.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
}
function dot(color: string): L.DivIcon {
return L.divIcon({
className: '',
@@ -103,11 +121,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
// One-time map creation.
useEffect(() => {
if (worldRef.current && !worldMap.current) {
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
// preferCanvas: the beam lobe is a dense FAN of translucent radials — up to
// ~120 thick strokes with a bidirectional Ultrabeam. As SVG that is ~120
// composited paths re-rasterised on every pan, zoom and redraw, which is
// enough to blow WebView2's raster budget and leave the window painting in
// patches. On canvas it is a single layer.
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true, preferCanvas: true })
.setView([20, 0], 1);
const bm = BASEMAPS[basemap];
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
addBasemap(m, basemap, baseLayer, labelsLayer);
worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m;
const sv = loadMapView();
@@ -115,7 +136,12 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
}
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
return () => window.clearTimeout(t);
// Resizing the pane is the ONLY thing that needs invalidateSize. Calling it on
// every overlay redraw (as before) forced a full repaint of the map each time
// the rotor moved a degree.
const ro = new ResizeObserver(() => worldMap.current?.invalidateSize());
if (worldRef.current) ro.observe(worldRef.current);
return () => { window.clearTimeout(t); ro.disconnect(); };
}, []);
// Swap the basemap (and its optional place-name overlay) when the operator
@@ -123,12 +149,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
// overlayPane, always above any tile layer, so nothing to re-stack there.
useEffect(() => {
const m = worldMap.current;
if (!m) return;
if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; }
if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; }
const bm = BASEMAPS[basemap];
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
if (m) addBasemap(m, basemap, baseLayer, labelsLayer);
}, [basemap]);
// Redraw overlays whenever the operator/DX grids (or beam) change.
@@ -224,9 +245,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
wm.setView([from.lat, from.lon], 3);
}
}
setTimeout(() => { wm.invalidateSize(); }, 0);
// No invalidateSize() here — a ResizeObserver handles the only case that needs
// it. Forcing a full map repaint on every redraw is what made a moving rotor
// thrash the compositor.
// Headings are rounded in the deps: a rotor reports a jittering float, and a
// tenth of a degree is invisible on a 5500 km lobe but rebuilds every polyline.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]);
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth,
boomAzimuth == null ? null : Math.round(boomAzimuth), autoZoom, zoomSignal]);
const path = pathBetween(fromGrid, toGrid);
+24 -8
View File
@@ -80,11 +80,23 @@ function StatusCell({ value }: { value?: string }) {
if (v === '') {
return <span className="block text-center text-[11px] text-muted-foreground"></span>;
}
// One colour per state, and each colour means something:
// Yes green — confirmed, the thing you wanted
// Requested blue — in flight, waiting on the other end. Not a problem.
// Modified orange — uploaded, then the QSO changed: it needs re-uploading.
// This is the ONLY state asking for action, so it gets the
// only alarming colour.
// No neutral — nothing done yet. Every freshly logged QSO is "No" on
// every row; painting that orange (as it used to be, in the
// same orange as Requested) made the table shout about a
// non-problem and told you nothing apart.
// Ignore dashed — deliberately excluded, on purpose.
const label = v === 'Y' ? t('qedit.qslYes') : v === 'R' ? t('qedit.qslRequested') : v === 'I' ? t('qedit.qslIgnore') : v === 'M' ? t('qedit.statusModified') : t('qedit.qslNo');
const cls = v === 'Y' ? 'bg-success text-success-foreground'
: v === 'R' ? 'bg-warning text-warning-foreground'
: v === 'I' ? 'bg-muted-foreground text-background'
: 'bg-warning text-warning-foreground';
const cls = v === 'Y' ? 'bg-success text-success-foreground border border-success'
: v === 'R' ? 'bg-info-muted text-info-muted-foreground border border-info-border'
: v === 'M' ? 'bg-warning text-warning-foreground border border-warning'
: v === 'I' ? 'bg-muted text-muted-foreground border border-dashed border-border italic'
: 'bg-muted text-muted-foreground border border-border';
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
}
@@ -571,9 +583,13 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
</p>
</div>
{/* Right: live status grid for every channel */}
<div className="w-72 shrink-0">
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
{/* Right: live status grid for every channel.
Sized by its content, not pinned to a width: a fixed 288px box
left the label column too narrow, so "QSL (paper)" wrapped onto
two lines and padded out the whole row. There is spare width to
the right — spend it on the label. */}
<div className="shrink-0">
<table className="border-separate" style={{ borderSpacing: 4 }}>
<thead>
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="text-left font-semibold">{t('qedit.thType')}</th>
@@ -584,7 +600,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
<tbody>
{CONFIRMATIONS.map((c) => (
<tr key={c.key} className="text-xs">
<td className="font-medium pr-2 py-0.5">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
<td className="font-medium pr-3 py-0.5 whitespace-nowrap">{CONF_LABEL_KEYS[c.key] ? t(CONF_LABEL_KEYS[c.key]) : c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td>
</tr>
+63 -7
View File
@@ -261,7 +261,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
cat: 'CAT interface',
rotator: 'PstRotator',
winkeyer: 'CW Keyer',
antenna: 'UltraBeam',
antenna: 'Ultrabeam / Steppir',
antgenius: 'Antenna Genius',
pgxl: 'Power Genius',
flex: 'FlexRadio',
@@ -826,9 +826,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Ultrabeam antenna (TCP) settings.
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: number; follow: boolean; step_khz: number }>({
enabled: false, host: '', port: 23, follow: false, step_khz: 50,
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number }>({
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50,
});
const [ubTesting, setUbTesting] = useState(false);
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -2267,16 +2267,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
function UltrabeamPanel() {
const isSteppir = ultrabeam.type === 'steppir';
const isSerial = isSteppir && ultrabeam.transport === 'serial';
return (
<>
<SectionHeader
title={t('hw.ultrabeam')}
title={t('hw.motorAntenna')}
/>
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
Enable Ultrabeam control
{t('hw.motorEnable')}
</label>
<div className="grid grid-cols-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="space-y-1 col-span-2">
<Label>Host / IP</Label>
@@ -2297,6 +2349,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
/>
</div>
</div>
)}
{isSteppir && (
<p className="text-xs text-muted-foreground">{t('hw.steppirHint')}</p>
)}
<div className="border-t border-border/60 pt-3 space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
@@ -2318,7 +2374,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)}
</div>
<div className="flex items-center gap-2 pt-2">
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || !ultrabeam.host.trim()}>
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || (isSerial ? !ultrabeam.com.trim() : !ultrabeam.host.trim())}>
{ubTesting ? t('hw.connecting') : t('hw.testConn')}
</Button>
</div>
+14 -8
View File
@@ -39,7 +39,7 @@ const en: Dict = {
'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.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',
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
// Language chooser
@@ -91,7 +91,7 @@ const en: Dict = {
'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.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
'gen.hint': 'App behaviour (saved instantly).',
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
@@ -165,7 +165,7 @@ 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.',
'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 12 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.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',
@@ -231,6 +231,7 @@ const en: Dict = {
'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 F1F6.', '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.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.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?',
@@ -247,7 +248,9 @@ const en: Dict = {
'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.',
'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': '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.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.',
@@ -302,7 +305,7 @@ const fr: Dict = {
'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.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ç',
'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.',
@@ -352,7 +355,7 @@ const fr: Dict = {
'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.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.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
@@ -417,7 +420,7 @@ 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.",
'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 12 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.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.",
@@ -476,6 +479,7 @@ const fr: Dict = {
'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 F1F6.', '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.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour nafficher 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.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 ?',
@@ -490,7 +494,9 @@ const fr: Dict = {
'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.',
'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 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.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.',
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.19.6';
export const APP_VERSION = '0.19.7';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+16
View File
@@ -38,6 +38,8 @@ export function ApplyAwardImport(arg1:string,arg2:Record<string, string>):Promis
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
export function ApplyAwardUpdate(arg1:string):Promise<void>;
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
export function AudioMonitorActive():Promise<boolean>;
@@ -138,6 +140,8 @@ export function DisconnectClusterServer(arg1:number):Promise<void>;
export function DiscoverFlexRadios():Promise<Array<cat.FlexRadio>>;
export function DismissAwardUpdate(arg1:string):Promise<void>;
export function DownloadAllReferenceLists():Promise<string>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
@@ -148,6 +152,8 @@ export function DownloadLoTWUsers():Promise<number>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
@@ -234,6 +240,10 @@ export function FlexSetProcessor(arg1:boolean):Promise<void>;
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
export function FlexSetRIT(arg1:boolean):Promise<void>;
export function FlexSetRITFreq(arg1:number):Promise<void>;
export function FlexSetRXAntenna(arg1:string):Promise<void>;
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
@@ -258,6 +268,10 @@ 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 GetActiveProfile():Promise<profile.Profile>;
@@ -282,6 +296,8 @@ export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
export function GetAwardUpdates():Promise<Array<main.AwardUpdate>>;
export function GetAwards():Promise<Array<award.Result>>;
export function GetBackupSettings():Promise<main.BackupSettings>;
+32
View File
@@ -34,6 +34,10 @@ export function ApplyAwardPreset(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
}
export function ApplyAwardUpdate(arg1) {
return window['go']['main']['App']['ApplyAwardUpdate'](arg1);
}
export function AssignAwardRefToQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AssignAwardRefToQSOs'](arg1, arg2, arg3);
}
@@ -234,6 +238,10 @@ export function DiscoverFlexRadios() {
return window['go']['main']['App']['DiscoverFlexRadios']();
}
export function DismissAwardUpdate(arg1) {
return window['go']['main']['App']['DismissAwardUpdate'](arg1);
}
export function DownloadAllReferenceLists() {
return window['go']['main']['App']['DownloadAllReferenceLists']();
}
@@ -254,6 +262,10 @@ export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
}
export function ExplainAward(arg1, arg2) {
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
}
export function ExportADIF(arg1, arg2) {
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
}
@@ -426,6 +438,14 @@ export function FlexSetProcessorLevel(arg1) {
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
}
export function FlexSetRIT(arg1) {
return window['go']['main']['App']['FlexSetRIT'](arg1);
}
export function FlexSetRITFreq(arg1) {
return window['go']['main']['App']['FlexSetRITFreq'](arg1);
}
export function FlexSetRXAntenna(arg1) {
return window['go']['main']['App']['FlexSetRXAntenna'](arg1);
}
@@ -474,6 +494,14 @@ export function FlexSetWNBLevel(arg1) {
return window['go']['main']['App']['FlexSetWNBLevel'](arg1);
}
export function FlexSetXIT(arg1) {
return window['go']['main']['App']['FlexSetXIT'](arg1);
}
export function FlexSetXITFreq(arg1) {
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
}
export function FlexTune(arg1) {
return window['go']['main']['App']['FlexTune'](arg1);
}
@@ -522,6 +550,10 @@ export function GetAwardStats(arg1) {
return window['go']['main']['App']['GetAwardStats'](arg1);
}
export function GetAwardUpdates() {
return window['go']['main']['App']['GetAwardUpdates']();
}
export function GetAwards() {
return window['go']['main']['App']['GetAwards']();
}
+179 -4
View File
@@ -260,9 +260,7 @@ export namespace award {
pattern: string;
leading_str?: string;
trailing_str?: string;
multi?: boolean;
dynamic?: boolean;
add_prefixes?: string[];
or_rules?: OrRule[];
dxcc_filter: number[];
valid_bands?: string[];
@@ -274,6 +272,8 @@ export namespace award {
export_credit_granted?: boolean;
total: number;
builtin: boolean;
version?: number;
user_edited?: boolean;
static createFrom(source: any = {}) {
return new Def(source);
@@ -300,9 +300,7 @@ export namespace award {
this.pattern = source["pattern"];
this.leading_str = source["leading_str"];
this.trailing_str = source["trailing_str"];
this.multi = source["multi"];
this.dynamic = source["dynamic"];
this.add_prefixes = source["add_prefixes"];
this.or_rules = this.convertValues(source["or_rules"], OrRule);
this.dxcc_filter = source["dxcc_filter"];
this.valid_bands = source["valid_bands"];
@@ -314,6 +312,116 @@ export namespace award {
this.export_credit_granted = source["export_credit_granted"];
this.total = source["total"];
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 {
@@ -365,6 +473,7 @@ export namespace award {
this.validated_bands = source["validated_bands"];
}
}
export class Result {
code: string;
name: string;
@@ -613,6 +722,10 @@ export namespace cat {
anf_level: number;
wnb: boolean;
wnb_level: number;
rit: boolean;
rit_freq: number;
xit: boolean;
xit_freq: number;
mode?: string;
cw_speed: number;
cw_pitch: number;
@@ -676,6 +789,10 @@ export namespace cat {
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.cw_speed = source["cw_speed"];
this.cw_pitch = source["cw_pitch"];
@@ -1267,6 +1384,38 @@ export namespace main {
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;
@@ -1408,6 +1557,24 @@ export namespace main {
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 {
enabled: boolean;
folder: string;
@@ -2298,8 +2465,12 @@ export namespace main {
}
export class UltrabeamSettings {
enabled: boolean;
type: string;
transport: string;
host: string;
port: number;
com: string;
baud: number;
follow: boolean;
step_khz: number;
@@ -2310,8 +2481,12 @@ export namespace main {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.type = source["type"];
this.transport = source["transport"];
this.host = source["host"];
this.port = source["port"];
this.com = source["com"];
this.baud = source["baud"];
this.follow = source["follow"];
this.step_khz = source["step_khz"];
}
+239 -20
View File
@@ -16,6 +16,7 @@ import (
"embed"
"encoding/json"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
@@ -72,9 +73,11 @@ type Def struct {
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
Multi bool `json:"multi,omitempty"` // a QSO may count for several references
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
// 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 ordered FALLBACK searches for the primary one above: they are
// tried IN ORDER and only while nothing has matched yet — the first rule that
@@ -94,11 +97,45 @@ type Def struct {
// --- Confirmation ---
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
Validate []string `json:"validate,omitempty"` // validated/granted sources
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
// controls have been removed from the editor — a checkbox that quietly does
// nothing is worse than no checkbox, because it is trusted. Wire these up (in
// internal/adif) before showing them again.
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
Builtin bool `json:"builtin"` // shipped default (informational)
// --- Catalog updates ---
// Version is the revision of a SHIPPED award. Bump it in the catalog JSON when
// you fix a definition (a better OR chain, a corrected reference list) and want
// that fix to reach operators who already run the award: on startup, a catalog
// award whose Version is higher than the stored one REPLACES it, definition and
// references. Awards created by the operator have no version and are never touched.
Version int `json:"version,omitempty"`
// UserEdited marks an award the operator has changed. A catalog update then
// SKIPS it: their work outranks ours. Set the moment the award (or its reference
// list) is saved to something other than what the catalog ships.
UserEdited bool `json:"user_edited,omitempty"`
}
// SameContent reports whether two definitions describe the same award — ignoring
// the bookkeeping fields (version, the user-edited flag, the derived builtin bit),
// which say where a definition came from, not what it does. Used to decide whether
// a save actually changed anything.
func (d Def) SameContent(o Def) bool {
a, b := d, o
a.Version, b.Version = 0, 0
a.UserEdited, b.UserEdited = false, false
a.Builtin, b.Builtin = false, false
ja, err1 := json.Marshal(a)
jb, err2 := json.Marshal(b)
if err1 != nil || err2 != nil {
return false
}
return string(ja) == string(jb)
}
// OrRule is one additional search OR'd with the award's primary matching rule.
@@ -200,6 +237,14 @@ func Catalog() []CatalogEntry {
}
}
}
// An award OpsLog SHIPS is built-in, by definition. Derive it here instead of
// trusting the flag in the file: you drop in an award you exported, its JSON
// says builtin:false (you wrote it, it wasn't built-in then), and it would
// quietly miss every future catalog correction. Making the author remember to
// flip a flag is exactly the kind of step nobody can guess.
for i := range out {
out[i].Def.Builtin = true
}
sort.Slice(out, func(i, j int) bool { return out[i].Def.Code < out[j].Def.Code })
return out
}
@@ -678,25 +723,149 @@ func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, tr
return found
}
// ── Explain: why did (or didn't) this QSO count for this award? ──────────────
//
// An award that silently matches nothing is the hardest kind of bug to see: the
// UI shows an empty column and the operator has no way to tell whether the QSO is
// out of scope, the field is empty, the rule looked in the wrong place, or the
// reference simply isn't on the list. Explain replays the matcher on ONE QSO and
// reports every step it took.
// Rejected is a candidate a rule produced that did not survive.
type Rejected struct {
Candidate string `json:"candidate"`
Reason string `json:"reason"`
}
// Step is one matching rule as it actually ran.
type Step struct {
Rule string `json:"rule"` // "primary", "OR 1", …
Field string `json:"field"` // the QSO field it scanned
MatchBy string `json:"match_by,omitempty"` // code | description | pattern
Exact bool `json:"exact,omitempty"` // whole field IS the reference
Pattern string `json:"pattern,omitempty"` // the rule's regex, if any
FieldValue string `json:"field_value,omitempty"` // what the field actually held
Candidates []string `json:"candidates,omitempty"` // what the rule produced, before validation
Kept []string `json:"kept,omitempty"` // what survived the reference list
Rejected []Rejected `json:"rejected,omitempty"` // and what didn't, with the reason
Skipped bool `json:"skipped,omitempty"` // never ran: an earlier rule already matched
Error string `json:"error,omitempty"` // e.g. a bad regex, which SKIPS the rule
}
// Explanation is the full account of one QSO against one award.
type Explanation struct {
Code string `json:"code"`
InScope bool `json:"in_scope"`
ScopeError string `json:"scope_error,omitempty"` // why the QSO is out of scope
Predefined bool `json:"predefined"` // matches are validated against a reference list
RefCount int `json:"ref_count"` // size of that list
Steps []Step `json:"steps"`
Manual []string `json:"manual,omitempty"` // references the operator assigned by hand
Result []string `json:"result"` // what the QSO finally counts for
}
// Explain runs the matcher on a single QSO and reports what it did. It goes
// through the SAME code path as Compute — not a re-implementation — so what it
// shows is what actually happens.
func Explain(d Def, metas []RefMeta, q *qso.QSO) Explanation {
ex := Explanation{Code: d.Code, Steps: []Step{}, Result: []string{}}
rl := NewRefList(metas)
ex.Predefined = len(metas) > 0 && !d.Dynamic
ex.RefCount = len(metas)
var why string
if !inScopeWhy(&d, q, &why) {
ex.ScopeError = why
return ex // out of scope: no rule ever runs, and saying so IS the answer
}
ex.InScope = true
var re *regexp.Regexp
if p := strings.TrimSpace(d.Pattern); p != "" {
c, err := compileAwardRE(p)
if err != nil {
ex.Steps = append(ex.Steps, Step{Rule: "primary", Field: d.Field, Pattern: d.Pattern,
Error: "bad regex: " + err.Error()})
return ex
}
re = c
}
candidatesTrace(&d, re, q, rl, len(metas) > 0, &ex)
return ex
}
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
return candidatesTrace(d, re, q, rl, hasList, nil)
}
// candidatesTrace is the matcher. ex is optional: when non-nil (only Explain
// passes it) each rule records what it scanned, what it produced and what was
// rejected. The tracing MUST live inside the real matcher rather than in a
// parallel "explain" implementation — a trace that can drift from the code it
// describes is worse than no trace, because it is believed.
func candidatesTrace(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool, ex *Explanation) []string {
predefined := hasList && !d.Dynamic
// run executes one rule and, when tracing, records it.
run := func(label, field, matchBy, pattern string, rex *regexp.Regexp, exact bool, leading, trailing, prefix string) []string {
raw := searchOne(field, matchBy, rex, exact, leading, trailing, prefix, q, rl, predefined)
kept := keepRefs(predefined, rl, raw)
if ex != nil {
s := Step{Rule: label, Field: field, MatchBy: matchBy, Exact: exact, Pattern: pattern,
FieldValue: strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing)),
Candidates: raw, Kept: kept}
keptSet := map[string]struct{}{}
for _, k := range kept {
keptSet[k] = struct{}{}
}
for _, c := range raw {
n := normalizeRef(c)
if _, ok := keptSet[n]; ok {
continue
}
s.Rejected = append(s.Rejected, rejection(predefined, rl, n))
}
ex.Steps = append(ex.Steps, s)
}
return kept
}
// Primary search first; the OR rules are ordered FALLBACKS — try the next
// only while nothing has matched yet, and stop at the first that yields a
// reference (short-circuit). So a province already found by NAME isn't also
// re-derived, possibly differently, from a later city-regex rule.
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
for i := 0; len(found) == 0 && i < len(d.OrRules); i++ {
//
// The short-circuit tests what a rule REALLY yielded — the references that
// survive the predefined list — not its raw candidates. A rule can always
// produce a raw candidate and still find nothing: "the whole ADDRESS field is
// the code" hands back "SERIATE (BG) 24068 ITALY", which is not a province.
// Testing the raw candidate would call that a hit, skip every fallback, and
// only then drop it as unlisted — leaving the QSO unmatched even though the
// next rule ("find the code inside the QTH") would have found BG.
found := run("primary", d.Field, d.MatchBy, d.Pattern, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "")
for i := range d.OrRules {
r := &d.OrRules[i]
label := fmt.Sprintf("OR %d", i+1)
if len(found) > 0 {
if ex != nil {
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Exact: r.ExactMatch,
Pattern: r.Pattern, Skipped: true})
}
continue // an earlier rule already matched — fallbacks short-circuit
}
var rre *regexp.Regexp
if p := strings.TrimSpace(r.Pattern); p != "" {
c, err := compileAwardRE(p)
if err != nil {
if ex != nil {
ex.Steps = append(ex.Steps, Step{Rule: label, Field: r.Field, MatchBy: r.MatchBy, Pattern: r.Pattern,
Error: "bad regex: " + err.Error()})
}
continue // skip a rule with a bad regex rather than failing the award
}
rre = c
}
found = 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
@@ -706,18 +875,55 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
// hand. Applied HERE (not just in MatchQSO) so Compute — which powers the
// awards panel and the per-QSO refs editor — honours overrides too. For a
// predefined award the ref is still validated against the list below.
for _, c := range manualRefs(q, d.Code) {
found = append(found, normalizeRef(c))
manual := keepRefs(predefined, rl, manualRefs(q, d.Code))
if ex != nil {
ex.Manual = manual
}
found = append(found, manual...)
out := dedupe(found)
if ex != nil {
ex.Result = out
}
return out
}
// rejection explains why a candidate the operator can SEE in the trace did not
// become a reference. "Nothing matched" is the least useful thing a matcher can
// say; every one of this week's award bugs was a rejection with a plain reason
// that nothing was printing.
func rejection(predefined bool, rl refList, code string) Rejected {
switch {
case code == "":
return Rejected{Candidate: code, Reason: "empty"}
case !predefined:
return Rejected{Candidate: code, Reason: "duplicate"}
}
m, ok := rl.byCode[code]
if !ok {
return Rejected{Candidate: code, Reason: "not in the award's reference list"}
}
if !m.Valid {
return Rejected{Candidate: code, Reason: "listed but disabled"}
}
return Rejected{Candidate: code, Reason: "duplicate"}
}
// keepRefs reduces a rule's raw candidates to the references that actually count.
// For a predefined award that means the codes on the list, enabled. The
// award-level DXCCFilter already scopes which QSOs are considered (see inScope),
// so we do NOT additionally require the QSO's entity to match the reference's own
// DXCC — that wrongly excluded e.g. WAS Alaska (state AK is DXCC entity 6, not
// 291). Per-reference DXCC stays metadata for the picker.
func keepRefs(predefined bool, rl refList, found []string) []string {
if !predefined {
return dedupe(found)
out := make([]string, 0, len(found))
for _, c := range found {
if c = normalizeRef(c); c != "" {
out = append(out, c)
}
}
return dedupe(out)
}
// Enforce the predefined list: keep only listed, valid references. The
// award-level DXCCFilter already scopes which QSOs are considered (see
// inScope), so we do NOT additionally require the QSO's entity to match the
// reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is
// DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker.
var out []string
seen := map[string]struct{}{}
for _, c := range found {
@@ -858,24 +1064,37 @@ func natLess(a, b string) bool {
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
// bands, modes, emission category, validity dates).
func inScope(d *Def, q *qso.QSO) bool {
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
func inScope(d *Def, q *qso.QSO) bool { return inScopeWhy(d, q, nil) }
// inScopeWhy is inScope with an optional explanation. why is filled ONLY when the
// QSO is out of scope AND a caller asked for the reason (Explain does; Compute,
// which runs this for every QSO × every award, passes nil and pays nothing).
// Keeping both behind one function is the point: a scope check that disagrees with
// the scope check it explains would be worse than no explanation at all.
func inScopeWhy(d *Def, q *qso.QSO, why *string) bool {
fail := func(format string, args ...any) bool {
if why != nil {
*why = fmt.Sprintf(format, args...)
}
return false
}
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
return fail("DXCC %d is not in the award's filter %v", q.DXCC, d.DXCCFilter)
}
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
return false
return fail("band %q is not among the valid bands %v", q.Band, d.ValidBands)
}
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
return false
return fail("mode %q is not among the valid modes %v", q.Mode, d.ValidModes)
}
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
return false
return fail("mode %q is %s emission; the award accepts %v", q.Mode, emissionOf(q.Mode), d.Emission)
}
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
return false
return fail("QSO of %s predates the award's start date (%s)", q.QSODate.Format("2006-01-02"), d.ValidFrom)
}
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
return false
return fail("QSO of %s is after the award's end date (%s)", q.QSODate.Format("2006-01-02"), d.ValidTo)
}
return true
}
+176
View File
@@ -3,7 +3,9 @@ package award
import (
"encoding/json"
"sort"
"strings"
"testing"
"time"
"hamlog/internal/qso"
)
@@ -287,6 +289,160 @@ func TestComputeGrid4VUCC(t *testing.T) {
}
}
// FFMA ships a fixed list of the 488 grids of the contiguous 48 states, taken
// from arrl.org/ffma. The count is the award's own checksum — if this test ever
// fails on the count, the catalog file is wrong, not the test.
func TestCatalogFFMA(t *testing.T) {
raw, ok := CatalogRefs("FFMA")
if !ok {
t.Fatal("FFMA has no reference list in the embedded catalog")
}
var refs []struct {
Code string `json:"code"`
DXCC int `json:"dxcc"`
Valid bool `json:"valid"`
}
if err := json.Unmarshal(raw, &refs); err != nil {
t.Fatalf("FFMA references: %v", err)
}
if len(refs) != 488 {
t.Fatalf("FFMA has %d grids, want exactly 488", len(refs))
}
var def Def
metas := make([]RefMeta, 0, len(refs))
for _, r := range refs {
// Rule 4(c): a grid may be activated from Canadian or Mexican soil, or from
// water. Pinning a reference to a DXCC entity would reject those contacts.
if r.DXCC != 0 {
t.Fatalf("FFMA grid %s is pinned to DXCC %d — rule 4(c) allows working it from outside the US", r.Code, r.DXCC)
}
metas = append(metas, RefMeta{Code: r.Code, Valid: r.Valid})
}
for _, d := range Defaults() {
if d.Code == "FFMA" {
def = d
}
}
if def.Total != 488 || def.Field != "grid4" {
t.Fatalf("FFMA def: total=%d field=%q, want 488 / grid4", def.Total, def.Field)
}
d1983 := time.Date(1990, 5, 1, 0, 0, 0, 0, time.UTC)
qsos := []qso.QSO{
{Callsign: "K5ABC", Band: "6m", Grid: "EM00AA", QSODate: d1983, LOTWRcvd: "Y"}, // counts
{Callsign: "VE3XYZ", Band: "6m", Grid: "FN25AA", QSODate: d1983, LOTWRcvd: "Y"}, // a VE3 in an FFMA grid: counts (4c)
{Callsign: "W1LINE", Band: "6m", VUCCGrids: "FN31,FN32", QSODate: d1983, QSLRcvd: "Y"}, // grid line: 2 grids (4d)
{Callsign: "G4XXX", Band: "6m", Grid: "IO91AA", QSODate: d1983, LOTWRcvd: "Y"}, // not an FFMA grid
{Callsign: "K5ABC", Band: "2m", Grid: "EM10AA", QSODate: d1983, LOTWRcvd: "Y"}, // wrong band
{Callsign: "K5OLD", Band: "6m", Grid: "EM20AA", QSODate: time.Date(1982, 6, 1, 0, 0, 0, 0, time.UTC), LOTWRcvd: "Y"}, // before 1983 (rule 2)
}
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"FFMA": metas}, nil)[0]
var got []string
for _, rf := range r.Refs {
if rf.Worked {
got = append(got, rf.Ref)
}
}
sort.Strings(got)
want := []string{"EM00", "FN25", "FN31", "FN32"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("FFMA worked = %v, want %v", got, want)
}
if r.Worked != len(want) || r.Total != 488 {
t.Errorf("FFMA worked=%d total=%d, want %d / 488", r.Worked, r.Total, len(want))
}
}
// WAIP: the primary rule takes the WHOLE address as the province code (exact
// match), which can never be one — but it does produce a raw candidate. The OR
// fallback (find the code inside the QTH, "SERIATE (BG)") is the rule that works,
// and it must still run: a rule that yields nothing VALID is not a hit.
func TestComputeOrFallbackAfterUnlistedPrimary(t *testing.T) {
def := Def{
Code: "WAIP", Name: "Worked All Italian Provinces", Valid: true,
Type: TypeReference, Field: "address", MatchBy: "code", ExactMatch: true,
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}}, // not exact → search inside the field
Confirm: []string{"lotw", "qsl"},
}
metas := []RefMeta{
{Code: "BG", Name: "Bergamo", Valid: true},
{Code: "MI", Name: "Milano", Valid: true},
}
qsos := []qso.QSO{
{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy", LOTWRcvd: "Y"},
}
r := Compute([]Def{def}, qsos, map[string][]RefMeta{"WAIP": metas}, nil)[0]
if r.Worked != 1 {
t.Fatalf("WAIP worked = %d, want 1 (BG, from the QTH fallback)", r.Worked)
}
for _, rf := range r.Refs {
if rf.Ref == "BG" && rf.Worked {
return
}
}
t.Errorf("BG not worked; refs = %v", refCodes(r))
}
// Explain must account for the WAIP case exactly: the primary rule produces a
// candidate that is NOT a province, says so, and the QTH fallback then finds BG.
func TestExplainAccountsForEveryRule(t *testing.T) {
def := Def{
Code: "WAIP", Valid: true, Type: TypeReference,
Field: "address", MatchBy: "code", ExactMatch: true,
OrRules: []OrRule{{Field: "qth", MatchBy: "code"}},
Confirm: []string{"lotw"},
}
metas := []RefMeta{{Code: "BG", Name: "Bergamo", Valid: true}}
q := &qso.QSO{Callsign: "I2IFT", Band: "30m", QTH: "SERIATE (BG)", Address: "Seriate (Bg) 24068 Italy"}
ex := Explain(def, metas, q)
if !ex.InScope || len(ex.Steps) != 2 {
t.Fatalf("in scope=%v, %d steps, want in scope with 2 (primary + OR 1): %+v", ex.InScope, len(ex.Steps), ex)
}
// The primary rule LOOKS like it found something. Saying only "no match" here is
// what cost hours: the trace has to show the candidate and why it lost.
p := ex.Steps[0]
if len(p.Candidates) == 0 {
t.Error("primary produced no candidate; the whole point is that it produces a bogus one")
}
if len(p.Kept) != 0 || len(p.Rejected) == 0 {
t.Fatalf("primary kept=%v rejected=%v, want nothing kept and a stated reason", p.Kept, p.Rejected)
}
if !strings.Contains(p.Rejected[0].Reason, "not in the award's reference list") {
t.Errorf("reason = %q, want it to name the real cause", p.Rejected[0].Reason)
}
if or1 := ex.Steps[1]; or1.Skipped || len(or1.Kept) != 1 || or1.Kept[0] != "BG" {
t.Errorf("OR 1 = %+v, want it to run and find BG", or1)
}
if len(ex.Result) != 1 || ex.Result[0] != "BG" {
t.Errorf("result = %v, want [BG]", ex.Result)
}
// A trace that disagrees with the matcher is worse than none: it is believed.
if got := MatchQSO(def, metas, q); strings.Join(got, ",") != strings.Join(ex.Result, ",") {
t.Errorf("Explain says %v but MatchQSO says %v — the trace does not describe the code", ex.Result, got)
}
}
func TestExplainOutOfScopeSaysWhy(t *testing.T) {
def := Def{Code: "FFMA", Valid: true, Field: "grid4", MatchBy: "code", ExactMatch: true,
ValidBands: []string{"6m"}, ValidFrom: "1983-01-01", Confirm: []string{"lotw"}}
q := &qso.QSO{Callsign: "K1ABC", Band: "2m", Grid: "EM00AA",
QSODate: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)}
ex := Explain(def, []RefMeta{{Code: "EM00", Valid: true}}, q)
if ex.InScope {
t.Fatal("a 2 m QSO is not in scope for a 6 m-only award")
}
if !strings.Contains(ex.ScopeError, "2m") {
t.Errorf("scope error = %q, want it to name the band that fails", ex.ScopeError)
}
if len(ex.Steps) != 0 {
t.Errorf("%d steps ran on an out-of-scope QSO; none should", len(ex.Steps))
}
}
func refCodes(r Result) []string {
out := make([]string, 0, len(r.Refs))
for _, rf := range r.Refs {
@@ -508,3 +664,23 @@ func TestCatalogSurvivesOneBadFile(t *testing.T) {
// Catalog() skips unparseable files rather than returning nil, so the others
// still load. (Verified structurally: the loader `continue`s on error.)
}
// Anything OpsLog SHIPS is built-in, whatever the file says.
//
// You create an award, export it (its JSON records builtin:false — it wasn't
// built-in when you wrote it), then drop that same file into the catalog to ship
// it. If we trusted the flag, the award would go out to everyone marked "not
// built-in" and would then silently miss every future catalog correction. Making
// the author remember to flip a flag first is a step nobody can guess — so the
// catalog derives it instead.
func TestCatalogForcesBuiltin(t *testing.T) {
for _, e := range Catalog() {
if !e.Def.Builtin {
t.Errorf("%s: shipped in the catalog but Builtin=false — it would miss catalog corrections", e.Def.Code)
}
}
// The realistic case: a user-authored award whose JSON says builtin:false.
if len(Catalog()) == 0 {
t.Fatal("empty catalog")
}
}
+107
View File
@@ -0,0 +1,107 @@
package award
import (
"encoding/json"
"fmt"
"testing"
"time"
"hamlog/internal/qso"
)
// A synthetic log with the shape a real one has: a spread of bands, modes,
// entities, and the free-text fields the harder awards actually scan.
func benchLog(n int) []qso.QSO {
bands := []string{"160m", "80m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"}
modes := []string{"SSB", "CW", "FT8", "FT4", "RTTY"}
entities := []int{291, 248, 227, 230, 339, 5, 108, 110, 6, 281}
towns := []string{"SERIATE (BG)", "MILANO (MI)", "ROMA (RM)", "TORINO (TO)", "NAPOLI (NA)"}
out := make([]qso.QSO, n)
base := time.Date(2015, 1, 1, 0, 0, 0, 0, time.UTC)
for i := range out {
e := entities[i%len(entities)]
out[i] = qso.QSO{
ID: int64(i + 1),
Callsign: fmt.Sprintf("I%dABC", i%10),
Band: bands[i%len(bands)],
Mode: modes[i%len(modes)],
DXCC: &e,
QSODate: base.Add(time.Duration(i) * time.Hour),
QTH: towns[i%len(towns)],
Address: "Via Roma 12, 24068 " + towns[i%len(towns)] + " Italy",
Grid: "JN45AA",
State: "CA",
LOTWRcvd: "Y",
}
}
return out
}
// WAIP is the worst realistic shape: a predefined list, an exact-match primary
// that always produces a doomed candidate, then a fallback chain that tokenises a
// free-text field. If anything is slow, it is this.
func benchWAIP() (Def, []RefMeta) {
d := Def{
Code: "WAIP", Valid: true, Type: TypeReference,
Field: "address", MatchBy: "code", ExactMatch: true,
OrRules: []OrRule{
{Field: "qth", MatchBy: "description"},
{Field: "qth", MatchBy: "code"},
{Field: "address", MatchBy: "code"},
},
DXCCFilter: []int{248},
Confirm: []string{"lotw", "qsl"},
}
codes := []string{"AG", "AL", "AN", "AO", "AP", "AQ", "AR", "AT", "AV", "BA", "BG", "BI", "BL", "BN", "BO", "BR", "BS", "BT", "BZ", "CA", "CB", "CE", "CH", "CL", "CN", "CO", "CR", "CS", "CT", "CZ", "EN", "FC", "FE", "FG", "FI", "FM", "FR", "GE", "GO", "GR", "IM", "IS", "KR", "LC", "LE", "LI", "LO", "LT", "LU", "MB", "MC", "ME", "MI", "MN", "MO", "MS", "MT", "NA", "NO", "NU", "OR", "PA", "PC", "PD", "PE", "PG", "PI", "PN", "PO", "PR", "PT", "PU", "PV", "PZ", "RA", "RC", "RE", "RG", "RI", "RM", "RN", "RO", "SA", "SI", "SO", "SP", "SR", "SS", "SU", "SV", "TA", "TE", "TN", "TO", "TP", "TR", "TS", "TV", "UD", "VA", "VB", "VC", "VE", "VI", "VR", "VT", "VV"}
metas := make([]RefMeta, 0, len(codes))
for _, c := range codes {
metas = append(metas, RefMeta{Code: c, Name: "Province " + c, Valid: true})
}
return d, metas
}
// One award, whole log — what GetAward(code) does when the Awards panel opens.
func BenchmarkComputeOneAward(b *testing.B) {
d, metas := benchWAIP()
for _, n := range []int{10_000, 50_000, 100_000} {
log := benchLog(n)
b.Run(fmt.Sprintf("WAIP/%dqso", n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
Compute([]Def{d}, log, map[string][]RefMeta{"WAIP": metas}, nil)
}
})
}
}
// Every shipped award at once — GetAwards(), the heaviest thing the app can ask.
func BenchmarkComputeWholeCatalog(b *testing.B) {
defs := Defaults()
metas := map[string][]RefMeta{}
wd, wm := benchWAIP()
defs = append(defs, wd)
metas["WAIP"] = wm
for _, c := range Catalog() {
if raw, ok := CatalogRefs(c.Def.Code); ok {
var refs []struct {
Code string `json:"code"`
Name string `json:"name"`
Valid bool `json:"valid"`
}
if json.Unmarshal(raw, &refs) == nil {
m := make([]RefMeta, 0, len(refs))
for _, r := range refs {
m = append(m, RefMeta{Code: r.Code, Name: r.Name, Valid: r.Valid})
}
metas[c.Def.Code] = m
}
}
}
for _, n := range []int{10_000, 100_000} {
log := benchLog(n)
b.Run(fmt.Sprintf("%dawards/%dqso", len(defs), n), func(b *testing.B) {
for i := 0; i < b.N; i++ {
Compute(defs, log, metas, nil)
}
})
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
{
"version": 1,
"exported_at": "2026-07-13T17:00:44Z",
"awards": [
{
"def": {
"code": "RAC",
"name": "RAC Canadian Provinces",
"description": "RAC Canadian Provinces",
"valid": true,
"protected": true,
"url": "https://www.rac.ca/canadaward/",
"valid_from": "1977-01-07",
"ref_display": "name",
"type": "QSOFIELDS",
"field": "state",
"match_by": "code",
"pattern": "",
"or_rules": [
{
"field": "qth",
"match_by": "description"
},
{
"field": "address",
"match_by": "description"
}
],
"dxcc_filter": [
1
],
"emission": [
"CW",
"PHONE",
"DIGITAL"
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": true
},
"references": [
{
"code": "AB",
"name": "Alberta",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "BC",
"name": "British Columbia",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "MB",
"name": "Manitoba",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NB",
"name": "New Brunswick",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NL",
"name": "Newfoundland and Labrador",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NS",
"name": "Nova Scotia",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NT",
"name": "Northwest Territories",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "NU",
"name": "Nunavut",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "ON",
"name": "Ontario",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "PE",
"name": "Prince Edward Island",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "QC",
"name": "Quebec",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "SK",
"name": "Saskatchewan",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "YT",
"name": "Yukon",
"dxcc": 0,
"group": "",
"subgrp": "",
"valid": true
}
]
}
]
}
+50
View File
@@ -0,0 +1,50 @@
{
"version": 1,
"exported_at": "2026-07-13T17:00:44Z",
"awards": [
{
"def": {
"code": "VUCC",
"name": "VHF/UHF Century Club",
"description": "VHF/UHF Century Club",
"valid": true,
"protected": true,
"url": "https://www.arrl.org/files/file/Awards%20Application%20Forms/VUCCRULE1a.pdf",
"valid_from": "1970-01-01",
"valid_to": "9999-01-30",
"type": "QSOFIELDS",
"field": "grid4",
"match_by": "code",
"exact_match": true,
"pattern": "",
"dynamic": true,
"dxcc_filter": null,
"valid_bands": [
"6m",
"4m",
"2m",
"70cm",
"23cm",
"13cm",
"1.25m"
],
"emission": [
"CW",
"PHONE",
"DIGITAL"
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": true
},
"references": null
}
]
}
+929
View File
@@ -0,0 +1,929 @@
{
"version": 1,
"exported_at": "2026-07-13T21:57:23Z",
"awards": [
{
"def": {
"code": "WAIP",
"name": "ARI Worked All Italian Provinces",
"valid": true,
"url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
"ref_url": "https://ari.it/en/english-area/awards/1734-waip-worked-all-italian-provinces.html",
"type": "REFERENCE",
"field": "qth",
"match_by": "code",
"exact_match": true,
"pattern": "",
"or_rules": [
{
"field": "qth",
"match_by": "description"
},
{
"field": "address",
"match_by": "code"
},
{
"field": "address",
"match_by": "description"
}
],
"dxcc_filter": [
248
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw",
"qsl"
],
"total": 0,
"builtin": false
},
"references": [
{
"code": "AG",
"name": "Agrigento",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "AL",
"name": "Alessandria",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "AN",
"name": "Ancona",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "AO",
"name": "Aosta",
"dxcc": 248,
"group": "Val d'Aosta",
"subgrp": "",
"valid": true
},
{
"code": "AP",
"name": "Ascoli Piceno",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "AQ",
"name": "L'Aquila",
"dxcc": 248,
"group": "Abruzzo",
"subgrp": "",
"valid": true
},
{
"code": "AR",
"name": "Arezzo",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "AT",
"name": "Asti",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "AV",
"name": "Avellino",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "BA",
"name": "Bari",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "BG",
"name": "Bergamo",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "BI",
"name": "Biella",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "BL",
"name": "Belluno",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "BN",
"name": "Benevento",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "BO",
"name": "Bologna",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "BR",
"name": "Brindisi",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "BS",
"name": "Brescia",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "BT",
"name": "Barletta-Andria-Trani",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "BZ",
"name": "Bolzano/Bozen",
"dxcc": 248,
"group": "Trentino-AltoAdige/Südtirol",
"subgrp": "",
"valid": true
},
{
"code": "CA",
"name": "Cagliari",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "CB",
"name": "Campobasso",
"dxcc": 248,
"group": "Molise",
"subgrp": "",
"valid": true
},
{
"code": "CE",
"name": "Caserta",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "CH",
"name": "Chieti",
"dxcc": 248,
"group": "Abruzzo",
"subgrp": "",
"valid": true
},
{
"code": "CI",
"name": "Carbonia-Iglesias",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "CL",
"name": "Caltanissetta",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "CN",
"name": "Cuneo",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "CO",
"name": "Como",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "CR",
"name": "Cremona",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "CS",
"name": "Cosenza",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
},
{
"code": "CT",
"name": "Catania",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "CZ",
"name": "Catanzaro",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
},
{
"code": "EN",
"name": "Enna",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "FC",
"name": "Forlì-Cesena",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "FE",
"name": "Ferrara",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "FG",
"name": "Foggia",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "FI",
"name": "Firenze",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "FM",
"name": "Fermo",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "FR",
"name": "Frosinone",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "GE",
"name": "Genova",
"dxcc": 248,
"group": "Liguria",
"subgrp": "",
"valid": true
},
{
"code": "GO",
"name": "Gorizia",
"dxcc": 248,
"group": "Friuli-Venezia Giulia",
"subgrp": "",
"valid": true
},
{
"code": "GR",
"name": "Grosseto",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "IM",
"name": "Imperia",
"dxcc": 248,
"group": "Liguria",
"subgrp": "",
"valid": true
},
{
"code": "IS",
"name": "Isernia",
"dxcc": 248,
"group": "Molise",
"subgrp": "",
"valid": true
},
{
"code": "KR",
"name": "Crotone",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
},
{
"code": "LC",
"name": "Lecco",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "LE",
"name": "Lecce",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "LI",
"name": "Livorno",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "LO",
"name": "Lodi",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "LT",
"name": "Latina",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "LU",
"name": "Lucca",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "MB",
"name": "Monza e Brianza",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "MC",
"name": "Macerata",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "ME",
"name": "Messina",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "MI",
"name": "Milano",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "MN",
"name": "Mantova",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "MO",
"name": "Modena",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "MS",
"name": "Massa-Carrara",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "MT",
"name": "Matera",
"dxcc": 248,
"group": "Basilicata",
"subgrp": "",
"valid": true
},
{
"code": "NA",
"name": "Napoli",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "NO",
"name": "Novara",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "NU",
"name": "Nuoro",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "OG",
"name": "Ogliastra",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "OR",
"name": "Oristano",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "OT",
"name": "Olbia-Tempio",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "PA",
"name": "Palermo",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "PC",
"name": "Piacenza",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "PD",
"name": "Padova",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "PE",
"name": "Pescara",
"dxcc": 248,
"group": "Abruzzo",
"subgrp": "",
"valid": true
},
{
"code": "PG",
"name": "Perugia",
"dxcc": 248,
"group": "Umbria",
"subgrp": "",
"valid": true
},
{
"code": "PI",
"name": "Pisa",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "PN",
"name": "Pordenone",
"dxcc": 248,
"group": "Friuli-Venezia Giulia",
"subgrp": "",
"valid": true
},
{
"code": "PO",
"name": "Prato",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "PR",
"name": "Parma",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "PT",
"name": "Pistoia",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "PU",
"name": "Pesaro-Urbino",
"dxcc": 248,
"group": "Marche",
"subgrp": "",
"valid": true
},
{
"code": "PV",
"name": "Pavia",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "PZ",
"name": "Potenza",
"dxcc": 248,
"group": "Basilicata",
"subgrp": "",
"valid": true
},
{
"code": "RA",
"name": "Ravenna",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "RC",
"name": "Reggio Calabria",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
},
{
"code": "RE",
"name": "Reggio Emilia",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "RG",
"name": "Ragusa",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "RI",
"name": "Rieti",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "RM",
"name": "Roma",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "RN",
"name": "Rimini",
"dxcc": 248,
"group": "Emilia-Romagna",
"subgrp": "",
"valid": true
},
{
"code": "RO",
"name": "Rovigo",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "SA",
"name": "Salerno",
"dxcc": 248,
"group": "Campania",
"subgrp": "",
"valid": true
},
{
"code": "SI",
"name": "Siena",
"dxcc": 248,
"group": "Toscana",
"subgrp": "",
"valid": true
},
{
"code": "SO",
"name": "Sondrio",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "SP",
"name": "La Spezia",
"dxcc": 248,
"group": "Liguria",
"subgrp": "",
"valid": true
},
{
"code": "SR",
"name": "Siracusa",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "SS",
"name": "Sassari",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "SV",
"name": "Savona",
"dxcc": 248,
"group": "Liguria",
"subgrp": "",
"valid": true
},
{
"code": "TA",
"name": "Taranto",
"dxcc": 248,
"group": "Puglia",
"subgrp": "",
"valid": true
},
{
"code": "TE",
"name": "Teramo",
"dxcc": 248,
"group": "Abruzzo",
"subgrp": "",
"valid": true
},
{
"code": "TN",
"name": "Trento",
"dxcc": 248,
"group": "Trentino-AltoAdige/Südtirol",
"subgrp": "",
"valid": true
},
{
"code": "TO",
"name": "Torino",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "TP",
"name": "Trapani",
"dxcc": 248,
"group": "Sicilia",
"subgrp": "",
"valid": true
},
{
"code": "TR",
"name": "Terni",
"dxcc": 248,
"group": "Umbria",
"subgrp": "",
"valid": true
},
{
"code": "TS",
"name": "Trieste",
"dxcc": 248,
"group": "Friuli-Venezia Giulia",
"subgrp": "",
"valid": true
},
{
"code": "TV",
"name": "Treviso",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "UD",
"name": "Udine",
"dxcc": 248,
"group": "Friuli-Venezia Giulia",
"subgrp": "",
"valid": true
},
{
"code": "VA",
"name": "Varese",
"dxcc": 248,
"group": "Lombardia",
"subgrp": "",
"valid": true
},
{
"code": "VB",
"name": "Verbano-Cusio-Ossola",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "VC",
"name": "Vercelli",
"dxcc": 248,
"group": "Piemonte",
"subgrp": "",
"valid": true
},
{
"code": "VE",
"name": "Venezia",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "VI",
"name": "Vicenza",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "VR",
"name": "Verona",
"dxcc": 248,
"group": "Veneto",
"subgrp": "",
"valid": true
},
{
"code": "VS",
"name": "Medio Campidano",
"dxcc": 248,
"group": "Sardegna",
"subgrp": "",
"valid": true
},
{
"code": "VT",
"name": "Viterbo",
"dxcc": 248,
"group": "Lazio",
"subgrp": "",
"valid": true
},
{
"code": "VV",
"name": "Vibo Valentia",
"dxcc": 248,
"group": "Calabria",
"subgrp": "",
"valid": true
}
]
}
]
}
+11
View File
@@ -304,6 +304,13 @@ type FlexTXState struct {
ANFLevel int `json:"anf_level"`
WNB bool `json:"wnb"`
WNBLevel int `json:"wnb_level"`
// RIT/XIT — offsets applied to the active slice's RX / TX frequency without
// moving the slice. The offset survives the switch being turned off, so
// re-enabling restores it, exactly like the radio's own knob.
RIT bool `json:"rit"`
RITFreq int `json:"rit_freq"`
XIT bool `json:"xit"`
XITFreq int `json:"xit_freq"`
// CW / mode-specific controls.
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
CWSpeed int `json:"cw_speed"`
@@ -378,6 +385,10 @@ type FlexController interface {
SetAPFLevel(int) error
SetWNB(bool) error
SetWNBLevel(int) error
SetRIT(bool) error
SetRITFreq(int) error
SetXIT(bool) error
SetXITFreq(int) error
// CW keyer + mode-specific controls.
SetCWSpeed(int) error
SetCWPitch(int) error
+45 -1
View File
@@ -90,6 +90,10 @@ type flexSlice struct {
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)
filterHi int // slice filter high cut (Hz)
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
@@ -707,7 +711,7 @@ func (f *Flex) handleStatus(payload string) {
f.mu.Unlock()
for _, id := range newIDs {
mi := f.meterMeta[id]
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s → sub", id, mi.src, mi.name, mi.unit)
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s lo=%g hi=%g → sub", id, mi.src, mi.name, mi.unit, mi.lo, mi.hi)
f.subscribeMeter(id)
}
}
@@ -804,6 +808,14 @@ func (f *Flex) handleStatus(payload string) {
s.wnb = val == "1"
case "wnb_level":
s.wnbLevel = atoiDefault(val, s.wnbLevel)
case "rit_on":
s.rit = val == "1"
case "rit_freq":
s.ritFreq = atoiDefault(val, s.ritFreq)
case "xit_on":
s.xit = val == "1"
case "xit_freq":
s.xitFreq = atoiDefault(val, s.xitFreq)
case "filter_lo":
s.filterLo = atoiDefault(val, s.filterLo)
case "filter_hi":
@@ -1315,6 +1327,10 @@ func (f *Flex) FlexState() FlexTXState {
st.APFLevel = rx.apfLevel
st.WNB = rx.wnb
st.WNBLevel = rx.wnbLevel
st.RIT = rx.rit
st.RITFreq = rx.ritFreq
st.XIT = rx.xit
st.XITFreq = rx.xitFreq
st.FilterLo = rx.filterLo
st.FilterHi = rx.filterHi
st.RXAnt = rx.rxAnt
@@ -1383,6 +1399,14 @@ func (f *Flex) sendSlice(param string, val any) error {
rx.rxAnt = fmt.Sprint(val)
case "txant":
rx.txAnt = fmt.Sprint(val)
case "rit_on":
rx.rit = val == "1"
case "rit_freq":
rx.ritFreq = toInt(val)
case "xit_on":
rx.xit = val == "1"
case "xit_freq":
rx.xitFreq = toInt(val)
}
}
f.mu.Unlock()
@@ -1490,6 +1514,26 @@ func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on))
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
// switch is off, so turning RIT back on restores the last offset — same as the
// radio's own knob.
func (f *Flex) SetRIT(on bool) error { return f.sendSlice("rit_on", boolFlex(on)) }
func (f *Flex) SetRITFreq(hz int) error { return f.sendSlice("rit_freq", clampOffset(hz)) }
func (f *Flex) SetXIT(on bool) error { return f.sendSlice("xit_on", boolFlex(on)) }
func (f *Flex) SetXITFreq(hz int) error { return f.sendSlice("xit_freq", clampOffset(hz)) }
// clampOffset keeps a RIT/XIT offset inside what SmartSDR accepts (±99 999 Hz).
func clampOffset(hz int) int {
if hz > 99999 {
return 99999
}
if hz < -99999 {
return -99999
}
return hz
}
func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) }
func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) }
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
+363
View File
@@ -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
}
+84
View File
@@ -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")
}
}
+24 -3
View File
@@ -49,19 +49,40 @@ func main() {
app := NewApp()
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
err := wails.Run(&options.App{
Title: "OpsLog",
Width: 1400,
Height: 900,
Width: width,
Height: height,
MinWidth: 1100,
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{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 250, G: 250, B: 249, A: 1},
OnStartup: app.startup,
OnDomReady: app.domReady,
OnBeforeClose: app.beforeClose,
OnShutdown: app.shutdown,
Bind: []interface{}{
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.19.6"
appVersion = "0.19.7"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.