feat: Added support for US Counties in OpsLog / Extra feature with DXHunter

This commit is contained in:
2026-07-17 13:23:35 +02:00
parent 1a155e3627
commit dd3b51a2ae
14 changed files with 4127 additions and 3 deletions
+161 -1
View File
@@ -54,6 +54,7 @@ import (
"hamlog/internal/settings" "hamlog/internal/settings"
"hamlog/internal/solar" "hamlog/internal/solar"
"hamlog/internal/steppir" "hamlog/internal/steppir"
"hamlog/internal/uls"
"hamlog/internal/ultrabeam" "hamlog/internal/ultrabeam"
"hamlog/internal/winkeyer" "hamlog/internal/winkeyer"
@@ -437,6 +438,7 @@ type App struct {
clusterEventCh chan clusterEvent clusterEventCh chan clusterEvent
clusterDropped int64 // spots/lines dropped when the queue was full (atomic) clusterDropped int64 // spots/lines dropped when the queue was full (atomic)
pota *pota.Cache pota *pota.Cache
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
awardRefs *awardref.Repo awardRefs *awardref.Repo
qslTemplates *qslcard.Repo qslTemplates *qslcard.Repo
operating *operating.Repo operating *operating.Repo
@@ -761,6 +763,14 @@ func (a *App) startup(ctx context.Context) {
} }
} }
a.settings.SetProfile(active.ID) a.settings.SetProfile(active.ID)
// US county resolver — its own local SQLite (data/uls.db), populated on demand
// by DownloadULSCounties. Opening (creating an empty store) is cheap and never
// fatal: county resolution simply stays inert until the operator downloads it.
if store, err := uls.Open(filepath.Join(a.dataDir, "uls.db")); err != nil {
applog.Printf("uls: open failed (county resolution disabled): %v", err)
} else {
a.uls = store
}
a.awardRefs = awardref.NewRepo(conn) a.awardRefs = awardref.NewRepo(conn)
a.qslTemplates = qslcard.NewRepo(conn) a.qslTemplates = qslcard.NewRepo(conn)
a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields) a.migrateAwardDefs() // upgrade legacy award definitions (enable + new fields)
@@ -1815,6 +1825,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}() }()
a.applyStationDefaults(&q, true) a.applyStationDefaults(&q, true)
a.applyDXCCNumber(&q) a.applyDXCCNumber(&q)
a.applyULSCounty(&q) // fill blank US county/grid from the offline ULS store
a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions a.applyClublogException(&q, false) // override entity for date-ranged DXpeditions
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
a.applyQSLDefaults(&q) a.applyQSLDefaults(&q)
@@ -4244,7 +4255,7 @@ func freeAwardCode(code string, taken map[string]int) string {
// builtinRefsVersion is bumped whenever the built-in reference data changes // builtinRefsVersion is bumped whenever the built-in reference data changes
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the // (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the
// derived lists. Bump this after correcting BuiltinRefs / the DXCC name table. // derived lists. Bump this after correcting BuiltinRefs / the DXCC name table.
const builtinRefsVersion = "2" const builtinRefsVersion = "3"
// seedBuiltinReferences populates the reference lists of built-in awards. // seedBuiltinReferences populates the reference lists of built-in awards.
// - First run (no version stored), or already up to date: seed only awards that // - First run (no version stored), or already up to date: seed only awards that
@@ -7793,6 +7804,155 @@ func (a *App) GetSlotStats() qso.SlotStats {
return st return st
} }
// --- US Counties (ULS) -----------------------------------------------------
// applyULSCounty fills a US QSO's blank county (and blank grid) from the offline
// ULS store. Non-US QSOs, an un-downloaded store, or already-filled fields make
// it a no-op — a value the operator or QRZ supplied is never overwritten, since
// the ZIP-derived county is only ~98% and the logged one is usually better. The
// award normalises whatever shape cnty holds, so stamping "ST,County" is safe.
func (a *App) applyULSCounty(q *qso.QSO) {
if a.uls == nil || q.DXCC == nil {
return
}
switch *q.DXCC {
case 291, 110, 6: // United States, Hawaii, Alaska
default:
return
}
haveCounty := strings.TrimSpace(q.County) != ""
haveGrid := strings.TrimSpace(q.Grid) != ""
if haveCounty && haveGrid {
return
}
loc, ok := a.uls.Resolve(q.Callsign)
if !ok {
return
}
if !haveCounty {
q.County = loc.CNTY()
if strings.TrimSpace(q.State) == "" {
q.State = loc.State
}
}
if !haveGrid && loc.Grid != "" {
q.Grid = loc.Grid
}
}
// ULSStatusResult reports whether the county database is loaded, and how fresh.
type ULSStatusResult struct {
Count int `json:"count"`
UpdatedAt string `json:"updated_at"` // RFC3339, empty if never downloaded
}
// ULSStatus returns the state of the offline US county database.
func (a *App) ULSStatus() ULSStatusResult {
if a.uls == nil {
return ULSStatusResult{}
}
var updated string
if t := a.uls.UpdatedAt(); !t.IsZero() {
updated = t.Format(time.RFC3339)
}
return ULSStatusResult{Count: a.uls.Count(), UpdatedAt: updated}
}
// DownloadULSCounties downloads and (re)builds the offline US county database in
// the background, emitting "uls:progress" ({stage,pct}) and "uls:done" ({ok,
// error,count}) so the Settings panel can show progress and reuse one flow.
func (a *App) DownloadULSCounties() error {
if a.uls == nil {
return fmt.Errorf("county database unavailable")
}
go func() {
defer func() {
if r := recover(); r != nil {
applog.Printf("PANIC in DownloadULSCounties: %v\n%s", r, debug.Stack())
wruntime.EventsEmit(a.ctx, "uls:done", map[string]any{"ok": false, "error": fmt.Sprintf("%v", r)})
}
}()
err := a.uls.Import(a.ctx, a.dataDir, func(stage string, pct int) {
wruntime.EventsEmit(a.ctx, "uls:progress", map[string]any{"stage": stage, "pct": pct})
})
res := map[string]any{"ok": err == nil, "count": a.uls.Count()}
if err != nil {
applog.Printf("uls: import failed: %v", err)
res["error"] = err.Error()
} else {
applog.Printf("uls: import complete — %d callsigns", a.uls.Count())
}
wruntime.EventsEmit(a.ctx, "uls:done", res)
}()
return nil
}
// BackfillUSCountiesResult summarises a bulk county/grid backfill over the log.
type BackfillUSCountiesResult struct {
Scanned int `json:"scanned"` // US QSOs examined
County int `json:"county"` // QSOs that gained a county
Grid int `json:"grid"` // QSOs that gained a grid
}
// BackfillUSCounties resolves county (and grid) for existing US QSOs that lack
// them, using the offline ULS store. It only FILLS blanks — an existing county
// is left as the operator logged it. Returns how many rows were updated.
func (a *App) BackfillUSCounties() (BackfillUSCountiesResult, error) {
var res BackfillUSCountiesResult
if a.uls == nil || a.uls.Count() == 0 {
return res, fmt.Errorf("county database not downloaded")
}
if a.qso == nil {
return res, fmt.Errorf("db not initialized")
}
rows, err := a.qso.List(a.ctx, qso.ListFilter{Limit: 1_000_000})
if err != nil {
return res, err
}
for i := range rows {
q := &rows[i]
if q.DXCC == nil {
continue
}
switch *q.DXCC {
case 291, 110, 6:
default:
continue
}
res.Scanned++
needCounty := strings.TrimSpace(q.County) == ""
needGrid := strings.TrimSpace(q.Grid) == ""
if !needCounty && !needGrid {
continue
}
loc, ok := a.uls.Resolve(q.Callsign)
if !ok {
continue
}
changed := false
if needCounty && loc.CNTY() != "" {
q.County = loc.CNTY()
if strings.TrimSpace(q.State) == "" {
q.State = loc.State
}
res.County++
changed = true
}
if needGrid && loc.Grid != "" {
q.Grid = loc.Grid
res.Grid++
changed = true
}
if changed {
if err := a.qso.Update(a.ctx, *q); err != nil {
applog.Printf("uls backfill: update qso %d failed: %v", q.ID, err)
}
}
}
a.invalidateAwardStats() // county changes affect the US Counties matrix
return res, nil
}
// DownloadConfirmations pulls confirmed QSOs from a service and updates the // DownloadConfirmations pulls confirmed QSOs from a service and updates the
// matching local QSOs' received status. LoTW only for now (the canonical // matching local QSOs' received status. LoTW only for now (the canonical
// confirmation system); runs in the background emitting the same // confirmation system); runs in the background emitting the same
+81
View File
@@ -0,0 +1,81 @@
package main
// One-shot generator: reads the FIPS county CSV and emits
// internal/awardref/uscounties_gen.go. Not part of the build.
import (
"bufio"
"fmt"
"os"
"sort"
"strings"
"hamlog/internal/award"
)
// dxccForState maps the two US "states" that are separate DXCC entities.
func dxccForState(st string) int {
switch st {
case "AK":
return 6
case "HI":
return 110
default:
return 291
}
}
// territories we exclude — USA-CA is the 50 states (+ DC).
var skipState = map[string]bool{"PR": true, "GU": true, "VI": true, "AS": true, "MP": true, "NA": true}
func main() {
f, err := os.Open(os.Args[1])
if err != nil {
panic(err)
}
defer f.Close()
type row struct{ code, name string; dxcc int }
var rows []row
seen := map[string]bool{}
sc := bufio.NewScanner(f)
sc.Scan() // header
for sc.Scan() {
line := sc.Text()
parts := strings.SplitN(line, ",", 3)
if len(parts) < 3 {
continue
}
name := strings.TrimSpace(parts[1])
st := strings.TrimSpace(parts[2])
if skipState[st] || len(st) != 2 || strings.EqualFold(name, "UNITED STATES") {
continue
}
// State header rows have an all-caps state NAME and state code "NA"
// (already skipped). County rows have a 2-letter state code.
code := award.USCountyKey(st, name)
if code == "" || seen[code] {
continue
}
seen[code] = true
rows = append(rows, row{code: code, name: name + ", " + st, dxcc: dxccForState(st)})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].code < rows[j].code })
var b strings.Builder
b.WriteString("// Code generated by cmd/cntygen from FIPS county data. DO NOT EDIT.\n")
b.WriteString("package awardref\n\n")
b.WriteString("// usCounties is the US Counties (USA-CA) reference list: one entry per county\n")
b.WriteString("// in the 50 states, keyed by the canonical \"STATE,COUNTY\" match code that\n")
b.WriteString("// award.usCountyKey produces from a QSO's state + cnty fields.\n")
fmt.Fprintf(&b, "func usCounties() []Ref {\n\treturn []Ref{\n")
for _, r := range rows {
fmt.Fprintf(&b, "\t\tref(%q, %q, %d),\n", r.code, r.name, r.dxcc)
}
b.WriteString("\t}\n}\n")
if err := os.WriteFile(os.Args[2], []byte(b.String()), 0o644); err != nil {
panic(err)
}
fmt.Printf("wrote %d counties to %s\n", len(rows), os.Args[2])
}
+95
View File
@@ -38,6 +38,7 @@ import {
GetPOTAToken, SavePOTAToken, GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
DownloadLoTWUsers, GetLoTWUsersStatus, DownloadLoTWUsers, GetLoTWUsersStatus,
DownloadULSCounties, ULSStatus, BackfillUSCounties,
ComputeStationInfo, ComputeStationInfo,
GetUIPref, SetUIPref, GetUIPref, SetUIPref,
GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas, GetFlexState, GetFlexBandAntennas, SaveFlexBandAntennas,
@@ -175,6 +176,7 @@ type SectionId =
| 'backup' | 'backup'
| 'database' | 'database'
| 'autostart' | 'autostart'
| 'uscounties'
| 'awards' | 'awards'
| 'cat' | 'cat'
| 'rotator' | 'rotator'
@@ -223,6 +225,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
]}, ]},
{ kind: 'item', label: t('sec.cluster'), id: 'cluster' }, { kind: 'item', label: t('sec.cluster'), id: 'cluster' },
{ kind: 'item', label: t('sec.udp'), id: 'udp' }, { kind: 'item', label: t('sec.udp'), id: 'udp' },
{ kind: 'item', label: t('sec.uscounties'), id: 'uscounties' },
{ kind: 'item', label: t('sec.database'), id: 'database' }, { kind: 'item', label: t('sec.database'), id: 'database' },
{ kind: 'item', label: t('sec.autostart'), id: 'autostart' }, { kind: 'item', label: t('sec.autostart'), id: 'autostart' },
], ],
@@ -238,6 +241,7 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations', station: 'sec.station', profiles: 'sec.profiles', operating: 'sec.operating', confirmations: 'sec.confirmations',
'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes', 'external-services': 'sec.external', lookup: 'sec.lookup', 'lists-bands': 'sec.bands', 'lists-modes': 'sec.modes',
cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp', cluster: 'sec.cluster', backup: 'sec.backup', database: 'sec.database', autostart: 'sec.autostart', udp: 'sec.udp',
uscounties: 'sec.uscounties',
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna', awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email', antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
}; };
@@ -1010,6 +1014,34 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); } catch (e: any) { setLotwTest({ ok: false, msg: String(e?.message ?? e) }); }
finally { setLotwUsersBusy(false); } finally { setLotwUsersBusy(false); }
}; };
// US Counties (offline FCC ULS) — download progress arrives via events.
const [ulsStatus, setUlsStatus] = useState<{ count: number; updated_at?: string }>({ count: 0 });
const [ulsBusy, setUlsBusy] = useState(false);
const [ulsProgress, setUlsProgress] = useState<{ stage: string; pct: number } | null>(null);
const [ulsMsg, setUlsMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [backfillBusy, setBackfillBusy] = useState(false);
const [backfillMsg, setBackfillMsg] = useState<string | null>(null);
useEffect(() => { ULSStatus().then((s) => setUlsStatus(s as any)).catch(() => {}); }, []);
useEffect(() => {
const off1 = EventsOn('uls:progress', (p: any) => setUlsProgress({ stage: p?.stage ?? '', pct: p?.pct ?? 0 }));
const off2 = EventsOn('uls:done', async (r: any) => {
setUlsBusy(false); setUlsProgress(null);
setUlsMsg(r?.ok ? { ok: true, text: t('uscty.done', { n: r?.count ?? 0 }) } : { ok: false, text: r?.error || 'error' });
try { const s = await ULSStatus(); setUlsStatus(s as any); } catch {}
});
return () => { off1(); off2(); };
}, []);
const downloadUls = async () => {
setUlsBusy(true); setUlsMsg(null); setUlsProgress({ stage: '', pct: 0 });
try { await DownloadULSCounties(); }
catch (e: any) { setUlsBusy(false); setUlsProgress(null); setUlsMsg({ ok: false, text: String(e?.message ?? e) }); }
};
const runBackfill = async () => {
setBackfillBusy(true); setBackfillMsg(null);
try { const r: any = await BackfillUSCounties(); setBackfillMsg(t('uscty.backfillDone', { c: r?.county ?? 0, g: r?.grid ?? 0, s: r?.scanned ?? 0 })); }
catch (e: any) { setBackfillMsg(String(e?.message ?? e)); }
finally { setBackfillBusy(false); }
};
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null); const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
const [hrdlogTesting, setHrdlogTesting] = useState(false); const [hrdlogTesting, setHrdlogTesting] = useState(false);
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null); const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -4332,6 +4364,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
); );
} }
function USCountiesPanel() {
const loaded = ulsStatus.count > 0;
return (
<div className="max-w-2xl space-y-5">
<div>
<h3 className="text-sm font-semibold mb-1">{t('uscty.title')}</h3>
<p className="text-xs text-muted-foreground leading-relaxed">{t('uscty.intro')}</p>
</div>
{/* Required-download notice */}
<div className="rounded-md border border-warning/40 bg-warning/10 p-3 text-xs text-foreground/90 leading-relaxed">
{t('uscty.needDownload')}
</div>
{/* Status + download */}
<div className="rounded-md border border-border p-3 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs">
<div className="font-medium">{t('uscty.dbStatus')}</div>
<div className="text-muted-foreground">
{loaded
? t('uscty.loaded', { n: ulsStatus.count.toLocaleString(), date: ulsStatus.updated_at ? new Date(ulsStatus.updated_at).toLocaleDateString() : '—' })
: t('uscty.notLoaded')}
</div>
</div>
<Button size="sm" onClick={downloadUls} disabled={ulsBusy}>
{ulsBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : <ArrowDown className="size-3.5 mr-1.5" />}
{loaded ? t('uscty.update') : t('uscty.download')}
</Button>
</div>
{ulsProgress && (
<div className="space-y-1">
<div className="text-[11px] text-muted-foreground flex justify-between">
<span>{ulsProgress.stage}</span><span>{ulsProgress.pct}%</span>
</div>
<div className="h-1.5 w-full rounded bg-muted overflow-hidden">
<div className="h-full bg-primary transition-all" style={{ width: `${ulsProgress.pct}%` }} />
</div>
</div>
)}
{ulsMsg && (
<div className={cn('text-xs', ulsMsg.ok ? 'text-success' : 'text-destructive')}>{ulsMsg.text}</div>
)}
</div>
{/* Backfill existing QSOs */}
<div className="rounded-md border border-border p-3 space-y-2">
<div className="text-xs font-medium">{t('uscty.backfillTitle')}</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">{t('uscty.backfillIntro')}</p>
<div className="flex items-center gap-3">
<Button size="sm" variant="secondary" onClick={runBackfill} disabled={backfillBusy || !loaded}>
{backfillBusy ? <Loader2 className="size-3.5 animate-spin mr-1.5" /> : null}
{t('uscty.backfillRun')}
</Button>
{backfillMsg && <span className="text-xs text-muted-foreground">{backfillMsg}</span>}
</div>
</div>
</div>
);
}
// Map sections to their content + icon (for placeholder). // Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = { const PANELS: Record<SectionId, () => JSX.Element> = {
general: GeneralPanel, general: GeneralPanel,
@@ -4348,6 +4442,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
udp: UDPIntegrationsPanelWrapper, udp: UDPIntegrationsPanelWrapper,
backup: BackupPanel, backup: BackupPanel,
database: DatabasePanel, database: DatabasePanel,
uscounties: USCountiesPanel,
autostart: () => <AutostartPanelComponent />, autostart: () => <AutostartPanelComponent />,
awards: () => <ComingSoon id="awards" icon={Award} />, awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel, cat: CATPanel,
+30 -2
View File
@@ -89,7 +89,21 @@ const en: Dict = {
'sec.confirmations': 'Confirmations', 'sec.external': 'External services', 'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup', 'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster', 'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties',
'uscty.title': 'US Counties (USA-CA)',
'uscty.intro': 'Resolve a US callsign to its county and grid offline, from the FCC ULS licence database. This powers the US Counties award and county hunting — including on CW/SSB, where a spot carries only a callsign.',
'uscty.needDownload': 'County resolution requires downloading the FCC database first (about 150 MB, stored locally). Nothing is resolved until you download it.',
'uscty.dbStatus': 'County database',
'uscty.loaded': '{n} callsigns · updated {date}',
'uscty.notLoaded': 'Not downloaded yet.',
'uscty.download': 'Download',
'uscty.update': 'Update',
'uscty.done': 'County database ready — {n} callsigns.',
'uscty.backfillTitle': 'Fill existing QSOs',
'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.',
'uscty.backfillRun': 'Fill missing counties',
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer', 'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices', 'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
// General panel // General panel
@@ -353,7 +367,21 @@ const fr: Dict = {
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes', 'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif", 'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster', 'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US',
'uscty.title': 'Comtés US (USA-CA)',
'uscty.intro': "Résout un indicatif US en comté et locator, hors-ligne, depuis la base de licences FCC ULS. Ça alimente le diplôme Comtés US et la chasse aux comtés — même en CW/SSB, où le spot ne porte qu'un indicatif.",
'uscty.needDownload': "La résolution des comtés nécessite d'abord de télécharger la base FCC (environ 150 Mo, stockée en local). Rien n'est résolu tant que tu ne l'as pas téléchargée.",
'uscty.dbStatus': 'Base des comtés',
'uscty.loaded': '{n} indicatifs · maj {date}',
'uscty.notLoaded': 'Pas encore téléchargée.',
'uscty.download': 'Télécharger',
'uscty.update': 'Mettre à jour',
'uscty.done': 'Base des comtés prête — {n} indicatifs.',
'uscty.backfillTitle': 'Compléter les QSO existants',
'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.",
'uscty.backfillRun': 'Remplir les comtés manquants',
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW', 'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio', 'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).', 'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
+6
View File
@@ -64,6 +64,8 @@ export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Reco
export function AwardsFolder():Promise<string>; export function AwardsFolder():Promise<string>;
export function BackfillUSCounties():Promise<main.BackfillUSCountiesResult>;
export function BrowseExecutable():Promise<string>; export function BrowseExecutable():Promise<string>;
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>; export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
@@ -150,6 +152,8 @@ export function DownloadConfirmations(arg1:string,arg2:boolean,arg3:string):Prom
export function DownloadLoTWUsers():Promise<number>; export function DownloadLoTWUsers():Promise<number>;
export function DownloadULSCounties():Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>; export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>; export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
@@ -808,6 +812,8 @@ export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>; export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
export function ULSStatus():Promise<main.ULSStatusResult>;
export function UltrabeamRetract():Promise<void>; export function UltrabeamRetract():Promise<void>;
export function UnlockSecrets(arg1:string):Promise<void>; export function UnlockSecrets(arg1:string):Promise<void>;
+12
View File
@@ -86,6 +86,10 @@ export function AwardsFolder() {
return window['go']['main']['App']['AwardsFolder'](); return window['go']['main']['App']['AwardsFolder']();
} }
export function BackfillUSCounties() {
return window['go']['main']['App']['BackfillUSCounties']();
}
export function BrowseExecutable() { export function BrowseExecutable() {
return window['go']['main']['App']['BrowseExecutable'](); return window['go']['main']['App']['BrowseExecutable']();
} }
@@ -258,6 +262,10 @@ export function DownloadLoTWUsers() {
return window['go']['main']['App']['DownloadLoTWUsers'](); return window['go']['main']['App']['DownloadLoTWUsers']();
} }
export function DownloadULSCounties() {
return window['go']['main']['App']['DownloadULSCounties']();
}
export function DuplicateProfile(arg1, arg2) { export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2); return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
} }
@@ -1574,6 +1582,10 @@ export function TestUltrabeam(arg1) {
return window['go']['main']['App']['TestUltrabeam'](arg1); return window['go']['main']['App']['TestUltrabeam'](arg1);
} }
export function ULSStatus() {
return window['go']['main']['App']['ULSStatus']();
}
export function UltrabeamRetract() { export function UltrabeamRetract() {
return window['go']['main']['App']['UltrabeamRetract'](); return window['go']['main']['App']['UltrabeamRetract']();
} }
+30
View File
@@ -1575,6 +1575,22 @@ export namespace main {
this.to = source["to"]; this.to = source["to"];
} }
} }
export class BackfillUSCountiesResult {
scanned: number;
county: number;
grid: number;
static createFrom(source: any = {}) {
return new BackfillUSCountiesResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.scanned = source["scanned"];
this.county = source["county"];
this.grid = source["grid"];
}
}
export class BackupSettings { export class BackupSettings {
enabled: boolean; enabled: boolean;
folder: string; folder: string;
@@ -2548,6 +2564,20 @@ export namespace main {
this.my_pota_ref = source["my_pota_ref"]; this.my_pota_ref = source["my_pota_ref"];
} }
} }
export class ULSStatusResult {
count: number;
updated_at: string;
static createFrom(source: any = {}) {
return new ULSStatusResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.count = source["count"];
this.updated_at = source["updated_at"];
}
}
export class UltrabeamSettings { export class UltrabeamSettings {
enabled: boolean; enabled: boolean;
type: string; type: string;
+55
View File
@@ -643,6 +643,59 @@ func InScope(d Def, q *qso.QSO) bool { return inScope(&d, q) }
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL). // EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
func EmissionOf(mode string) string { return emissionOf(mode) } func EmissionOf(mode string) string { return emissionOf(mode) }
// USCountyKey normalises a QSO's state + cnty into the canonical "STATE,COUNTY"
// match code used by the US Counties (USA-CA) award, so the reference list (in
// that same form) matches whatever shape the logbook holds. It is the SINGLE
// source of truth: cmd/cntygen builds the reference codes by calling it, so the
// two sides can never drift.
//
// Real logs are a mess — "MA,MIDDLESEX", bare "Middlesex" with the state in its
// own column, mixed case, county-type suffixes, the odd "0", plus the FIPS list
// abbreviating "Saint"→"St." and hyphenating "Matanuska-Susitna". The rules:
// - if cnty already carries "ST,County", split on the first comma; else take
// the state from the STATE column;
// - upper-case; drop periods and apostrophes; hyphens→space; strip a trailing
// County/Parish/Borough/Census Area/Municipality; fold Saint(e)→St(e);
// collapse whitespace;
// - require a 2-letter state and a non-empty county, else no match ("").
func USCountyKey(state, cnty string) string {
s := strings.TrimSpace(cnty)
if s == "" || s == "0" {
return ""
}
var st, co string
if i := strings.IndexByte(s, ','); i >= 0 {
st, co = strings.TrimSpace(s[:i]), strings.TrimSpace(s[i+1:])
} else {
st, co = strings.TrimSpace(state), s
}
co = strings.ToUpper(co)
co = strings.ReplaceAll(co, ".", "")
co = strings.ReplaceAll(co, "'", "")
co = strings.ReplaceAll(co, "-", " ")
for _, suf := range []string{" COUNTY", " PARISH", " BOROUGH", " CENSUS AREA", " MUNICIPALITY"} {
if strings.HasSuffix(co, suf) {
co = strings.TrimSuffix(co, suf)
break
}
}
switch {
case strings.HasPrefix(co, "SAINTE "):
co = "STE " + co[len("SAINTE "):]
case strings.HasPrefix(co, "SAINT "):
co = "ST " + co[len("SAINT "):]
}
// Drop ALL internal spaces last: FIPS writes DeKalb/DuPage/LaSalle as one
// word, logs often split them ("De Kalb"). Removing spaces on both sides
// folds those together and can't collide two real counties in one state.
co = strings.ReplaceAll(co, " ", "")
st = strings.ToUpper(st)
if len(st) != 2 || co == "" {
return ""
}
return st + "," + co
}
// labelRef fills a worked reference's name/group from the reference list (or the // labelRef fills a worked reference's name/group from the reference list (or the
// name resolver as a fallback). // name resolver as a fallback).
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) { func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
@@ -1145,6 +1198,8 @@ func fieldRaw(field string, q *qso.QSO) string {
return q.Callsign return q.Callsign
case "state": case "state":
return q.State return q.State
case "us_county":
return USCountyKey(q.State, q.County)
case "cont": case "cont":
return q.Continent return q.Continent
case "country": case "country":
+29
View File
@@ -0,0 +1,29 @@
{
"def": {
"code": "USCOUNTIES",
"name": "US Counties (USA-CA)",
"description": "Worked US counties (CQ USA-CA). Matches the QSO's county, tolerating LoTW \"ST,County\" and bare county-name shapes.",
"valid": true,
"protected": true,
"type": "QSOFIELDS",
"field": "us_county",
"match_by": "code",
"exact_match": true,
"pattern": "",
"ref_display": "name",
"dxcc_filter": [
291,
110,
6
],
"confirm": [
"lotw",
"qsl"
],
"validate": [
"lotw"
],
"total": 3077,
"builtin": true
}
}
+27
View File
@@ -0,0 +1,27 @@
package award
import "testing"
func TestUSCountyKey(t *testing.T) {
cases := []struct {
state, cnty, want string
}{
{"MA", "MA,MIDDLESEX", "MA,MIDDLESEX"}, // LoTW "ST,County" shape
{"NJ", "Middlesex", "NJ,MIDDLESEX"}, // bare name + state column
{"TX", "Montgomery", "TX,MONTGOMERY"}, // title case
{"FL", "Saint Lucie", "FL,STLUCIE"}, // Saint→St, space dropped
{"FL", "St. Lucie", "FL,STLUCIE"}, // FIPS abbreviation, period dropped
{"AK", "Matanuska-Susitna", "AK,MATANUSKASUSITNA"}, // hyphen→space→dropped
{"IL", "De Kalb", "IL,DEKALB"}, // split name
{"IL", "DeKalb County", "IL,DEKALB"}, // suffix + one word
{"LA", "Acadia Parish", "LA,ACADIA"}, // parish suffix
{"", "Honolulu", ""}, // no state → no match
{"HI", "0", ""}, // garbage
{"HI", "", ""}, // empty
}
for _, c := range cases {
if got := USCountyKey(c.state, c.cnty); got != c.want {
t.Errorf("USCountyKey(%q,%q) = %q, want %q", c.state, c.cnty, got, c.want)
}
}
}
+3
View File
@@ -17,6 +17,7 @@ import (
// - WAC → continent code ("EU", "NA", …) // - WAC → continent code ("EU", "NA", …)
// - WAS → ADIF STATE code ("AL", …) // - WAS → ADIF STATE code ("AL", …)
// - DDFM → "D06" (the award pattern captures the leading D) // - DDFM → "D06" (the award pattern captures the leading D)
// - USCOUNTIES → canonical "STATE,COUNTY" key (see award.usCountyKey)
func BuiltinRefs(code string) ([]Ref, bool) { func BuiltinRefs(code string) ([]Ref, bool) {
switch code { switch code {
case "DXCC": case "DXCC":
@@ -29,6 +30,8 @@ func BuiltinRefs(code string) ([]Ref, bool) {
return usStates().Refs, true return usStates().Refs, true
case "DDFM": case "DDFM":
return frenchDepartments(), true return frenchDepartments(), true
case "USCOUNTIES":
return usCounties(), true
} }
return nil, false return nil, false
} }
File diff suppressed because it is too large Load Diff
+403
View File
@@ -0,0 +1,403 @@
// Package uls resolves a US amateur callsign to its county and grid, offline,
// from the FCC ULS licence database cross-referenced with a ZIP→county/lat-lon
// table. It backs the US Counties (USA-CA) award and county hunting: an FCC
// spot or a bare CW/SSB spot carries only a callsign, and this turns that into
// a county with no per-lookup API call.
//
// Data lives in its OWN local SQLite file (data/uls.db), never in the logbook:
// it is ~800k rows of static reference data that would only bloat the log (and
// crawl over a remote MySQL link). It is downloaded on demand, not shipped.
//
// Sources, both public and free:
// - FCC ULS Amateur, full database: l_amat.zip → EN.dat (pipe-delimited).
// Fields used: [4] call_sign, [17] state, [18] zip_code.
// - GeoNames US postal codes: US.zip → US.txt (tab-delimited).
// Fields used: [1] zip, [4] state, [5] county, [9] lat, [10] lon.
//
// The county from a ZIP is the ZIP's primary county — a ZIP can straddle a line,
// so this is ~98% right for fixed stations (rovers/portables need the from-air
// grid, which arrives separately via heard_geo). Good enough to hunt with.
package uls
import (
"archive/zip"
"bufio"
"context"
"database/sql"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
// Default download URLs (overridable in Import for tests).
const (
fccAmateurURL = "https://data.fcc.gov/download/pub/uls/complete/l_amat.zip"
geoNamesURL = "https://download.geonames.org/export/zip/US.zip"
)
// Location is a resolved callsign's home county + grid.
type Location struct {
State string `json:"state"`
County string `json:"county"` // GeoNames county name (e.g. "Middlesex")
Grid string `json:"grid"` // 6-char Maidenhead from the ZIP centroid
}
// CNTY renders the ADIF "STATE,County" form for stamping a QSO's cnty field.
func (l Location) CNTY() string {
if l.State == "" || l.County == "" {
return ""
}
return l.State + "," + l.County
}
// Store owns the local uls.db connection.
type Store struct {
db *sql.DB
mu sync.RWMutex // guards the whole DB during a re-import (DELETE+bulk INSERT)
}
// Open opens (creating if needed) the ULS SQLite store at path.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, err
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS uls_callsign (
callsign TEXT PRIMARY KEY,
state TEXT NOT NULL DEFAULT '',
county TEXT NOT NULL DEFAULT '',
grid TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS uls_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);`); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
// Count returns how many callsigns are loaded.
func (s *Store) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
var n int
s.db.QueryRow(`SELECT COUNT(*) FROM uls_callsign`).Scan(&n)
return n
}
// UpdatedAt returns when the store was last imported (zero if never).
func (s *Store) UpdatedAt() time.Time {
s.mu.RLock()
defer s.mu.RUnlock()
var v string
if err := s.db.QueryRow(`SELECT value FROM uls_meta WHERE key='updated_at'`).Scan(&v); err != nil {
return time.Time{}
}
t, _ := time.Parse(time.RFC3339, v)
return t
}
// Resolve looks up a callsign's home county + grid. ok=false if unknown or the
// store is empty.
func (s *Store) Resolve(callsign string) (Location, bool) {
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
return Location{}, false
}
s.mu.RLock()
defer s.mu.RUnlock()
var l Location
err := s.db.QueryRow(`SELECT state, county, grid FROM uls_callsign WHERE callsign=?`, call).
Scan(&l.State, &l.County, &l.Grid)
if err != nil || l.County == "" {
return Location{}, false
}
return l, true
}
// Progress reports import stages to the caller (0..100 within a stage).
type Progress func(stage string, pct int)
// zipRow is one ZIP's primary county + centroid.
type zipRow struct {
state, county string
lat, lon float64
}
// Import downloads the FCC ULS + GeoNames data, rebuilds the callsign→county
// table, and records the timestamp. It replaces the table atomically: on any
// error the previous contents are left intact. tmpDir is where the (large) zips
// are streamed; "" uses the OS temp dir.
func (s *Store) Import(ctx context.Context, tmpDir string, prog Progress) error {
if prog == nil {
prog = func(string, int) {}
}
if tmpDir == "" {
tmpDir = os.TempDir()
}
// 1) ZIP→county/lat-lon crosswalk (small).
prog("Downloading ZIP crosswalk", 0)
geoPath := filepath.Join(tmpDir, "opslog_geonames_us.zip")
if err := download(ctx, geoNamesURL, geoPath, nil); err != nil {
return fmt.Errorf("download GeoNames: %w", err)
}
defer os.Remove(geoPath)
prog("Parsing ZIP crosswalk", 50)
zipmap, err := parseGeoNames(geoPath)
if err != nil {
return fmt.Errorf("parse GeoNames: %w", err)
}
if len(zipmap) == 0 {
return fmt.Errorf("GeoNames crosswalk is empty")
}
// 2) FCC ULS full amateur database (large).
prog("Downloading FCC ULS database", 0)
amatPath := filepath.Join(tmpDir, "opslog_l_amat.zip")
if err := download(ctx, fccAmateurURL, amatPath, func(pct int) { prog("Downloading FCC ULS database", pct) }); err != nil {
return fmt.Errorf("download FCC ULS: %w", err)
}
defer os.Remove(amatPath)
// 3) Parse EN.dat, join the crosswalk, rebuild the table.
prog("Building county database", 0)
return s.rebuild(ctx, amatPath, zipmap, prog)
}
// rebuild streams EN.dat out of the FCC zip and replaces uls_callsign in one
// transaction (old data survives a failure).
func (s *Store) rebuild(ctx context.Context, amatZip string, zipmap map[string]zipRow, prog Progress) error {
zr, err := zip.OpenReader(amatZip)
if err != nil {
return fmt.Errorf("open FCC zip: %w", err)
}
defer zr.Close()
var en *zip.File
for _, f := range zr.File {
if strings.EqualFold(filepath.Base(f.Name), "EN.dat") {
en = f
break
}
}
if en == nil {
return fmt.Errorf("EN.dat not found in FCC zip")
}
rc, err := en.Open()
if err != nil {
return fmt.Errorf("open EN.dat: %w", err)
}
defer rc.Close()
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM uls_callsign`); err != nil {
return err
}
stmt, err := tx.PrepareContext(ctx, `INSERT OR REPLACE INTO uls_callsign(callsign,state,county,grid) VALUES(?,?,?,?)`)
if err != nil {
return err
}
defer stmt.Close()
sc := bufio.NewScanner(rc)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var n, kept int
for sc.Scan() {
if ctx.Err() != nil {
return ctx.Err()
}
n++
f := strings.Split(sc.Text(), "|")
if len(f) < 19 {
continue
}
call := strings.ToUpper(strings.TrimSpace(f[4]))
if call == "" {
continue
}
zip5 := zip5Of(f[18])
zr, ok := zipmap[zip5]
if !ok {
continue // no crosswalk entry → can't place it
}
state := strings.ToUpper(strings.TrimSpace(f[17]))
if state == "" {
state = zr.state
}
if _, err := stmt.ExecContext(ctx, call, state, zr.county, grid6(zr.lat, zr.lon)); err != nil {
return err
}
kept++
if kept%50000 == 0 {
prog("Building county database", int(math.Min(99, float64(kept)/8000)))
}
}
if err := sc.Err(); err != nil {
return fmt.Errorf("read EN.dat: %w", err)
}
if _, err := tx.ExecContext(ctx, `INSERT OR REPLACE INTO uls_meta(key,value) VALUES('updated_at',?)`,
time.Now().UTC().Format(time.RFC3339)); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
prog("Done", 100)
return nil
}
// parseGeoNames reads US.txt out of the GeoNames zip into a zip→row map,
// keeping the first (primary) county seen for each ZIP.
func parseGeoNames(zipPath string) (map[string]zipRow, error) {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return nil, err
}
defer zr.Close()
var txt *zip.File
for _, f := range zr.File {
if strings.EqualFold(filepath.Base(f.Name), "US.txt") {
txt = f
break
}
}
if txt == nil {
return nil, fmt.Errorf("US.txt not found")
}
rc, err := txt.Open()
if err != nil {
return nil, err
}
defer rc.Close()
out := make(map[string]zipRow, 45000)
sc := bufio.NewScanner(rc)
sc.Buffer(make([]byte, 0, 64*1024), 256*1024)
for sc.Scan() {
f := strings.Split(sc.Text(), "\t")
if len(f) < 11 {
continue
}
zip5 := strings.TrimSpace(f[1])
if zip5 == "" {
continue
}
if _, dup := out[zip5]; dup {
continue
}
lat := parseFloat(f[9])
lon := parseFloat(f[10])
out[zip5] = zipRow{
state: strings.ToUpper(strings.TrimSpace(f[4])),
county: strings.TrimSpace(f[5]),
lat: lat,
lon: lon,
}
}
return out, sc.Err()
}
// download streams url to dest, reporting percent when the content length is
// known and prog is non-nil.
func download(ctx context.Context, url, dest string, prog func(pct int)) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
}
f, err := os.Create(dest)
if err != nil {
return err
}
defer f.Close()
var body io.Reader = resp.Body
if prog != nil && resp.ContentLength > 0 {
body = &progReader{r: resp.Body, total: resp.ContentLength, prog: prog}
}
_, err = io.Copy(f, body)
return err
}
type progReader struct {
r io.Reader
total int64
read int64
last int
prog func(pct int)
}
func (p *progReader) Read(b []byte) (int, error) {
n, err := p.r.Read(b)
p.read += int64(n)
if pct := int(p.read * 100 / p.total); pct != p.last {
p.last = pct
p.prog(pct)
}
return n, err
}
func zip5Of(z string) string {
z = strings.TrimSpace(z)
if len(z) > 5 {
z = z[:5]
}
return z
}
func parseFloat(s string) float64 {
var v float64
fmt.Sscanf(strings.TrimSpace(s), "%g", &v)
return v
}
// grid6 converts latitude/longitude to a 6-character Maidenhead locator.
func grid6(lat, lon float64) string {
if lat == 0 && lon == 0 {
return ""
}
lon += 180
lat += 90
if lon < 0 || lon >= 360 || lat < 0 || lat >= 180 {
return ""
}
f0 := int(lon / 20)
f1 := int(lat / 10)
sq0 := int(math.Mod(lon, 20) / 2)
sq1 := int(math.Mod(lat, 10) / 1)
su0 := int(math.Mod(lon, 2) / (2.0 / 24))
su1 := int(math.Mod(lat, 1) / (1.0 / 24))
return string([]byte{
byte('A' + f0), byte('A' + f1),
byte('0' + sq0), byte('0' + sq1),
byte('a' + su0), byte('a' + su1),
})
}
+42
View File
@@ -0,0 +1,42 @@
package uls
import (
"os"
"testing"
)
func TestGrid6(t *testing.T) {
cases := []struct {
lat, lon float64
want string
}{
{38.90, -77.03, "FM18lw"}, // Washington DC
{40.71, -74.00, "FN30xr"}, // New York
{34.05, -118.24, "DM04vd"},// Los Angeles
}
for _, c := range cases {
if got := grid6(c.lat, c.lon); got[:4] != c.want[:4] {
t.Errorf("grid6(%v,%v)=%q want field/square %q", c.lat, c.lon, got, c.want[:4])
}
}
}
// TestParseGeoNames runs against the real GeoNames US.zip if present in the
// scratchpad (downloaded during development); skipped otherwise.
func TestParseGeoNames(t *testing.T) {
path := os.Getenv("GEONAMES_ZIP")
if path == "" {
t.Skip("set GEONAMES_ZIP to the US.zip path to run")
}
m, err := parseGeoNames(path)
if err != nil {
t.Fatal(err)
}
if len(m) < 30000 {
t.Fatalf("expected >30k ZIPs, got %d", len(m))
}
// A known ZIP: 20500 = The White House, DC.
if r, ok := m["20500"]; !ok || r.state != "DC" {
t.Errorf("ZIP 20500 = %+v (ok=%v)", r, ok)
}
}