Initial codebase: Go + Wails amateur radio logbook

Backend (Go 1.25 / Wails v2):
- QSO storage on SQLite (modernc) with embedded migrations (0001..0005)
- Streaming ADIF import (batch insert) + WorkedBefore per callsign and DXCC
- Callsign lookup with QRZ.com + HamQTH providers (primary/failsafe routing)
  and SQLite-backed TTL cache
- DXCC resolver from cty.dat (auto-download, longest-prefix-match)
- Multi-profile operator identities (home/portable/SOTA/contest) — every
  QSO stamps MY_* from the active profile
- CAT control via OmniRig COM on a single OS-locked goroutine, with
  bidirectional sync (freq/mode/band/split/VFOs) and Rig1/Rig2 hot-swap
- Settings store (key/value), CAT debug log at %APPDATA%/HamLog/cat.log

Frontend (React 18 + TypeScript + Tailwind v4 + shadcn-style):
- Single-row entry strip with CAT-aware band/mode/freq, RST, Start/End
  UTC, per-field locks (band/mode/freq/start/end) for backdated QSOs
- Topbar: live freq (MHz.kHz.Hz dotted), live UTC, band/mode/SPLIT badges,
  CAT pill with rig selector and clickable Azimuth pill (rotor TODO)
- Settings tree: Profiles (Log4OM-style manager), Station Information
  (edits the active profile), unified Callsign Lookup with Test buttons,
  Bands/Modes lists, CAT
- Worked-before matrix (band × mode × class) with new-DXCC highlighting
- ADIF import from menu + Maintenance > Refresh cty.dat

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 00:16:45 +02:00
parent 734d296300
commit 7ace2cc602
87 changed files with 15892 additions and 0 deletions
+364
View File
@@ -0,0 +1,364 @@
package adif
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"hamlog/internal/qso"
)
// ImportResult summarises an ADIF import for the UI.
type ImportResult struct {
Total int `json:"total"` // records found in the file
Imported int `json:"imported"` // successfully inserted
Skipped int `json:"skipped"` // dropped (missing required fields, etc.)
Errors []string `json:"errors"` // up to maxErrors error messages
}
const maxErrors = 50
// Importer streams an ADI file into a QSO repository.
type Importer struct {
Repo *qso.Repo
BatchSize int // 0 → 500
}
// ImportFile opens the file at path and imports it into the repo.
func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult, error) {
f, err := os.Open(path)
if err != nil {
return ImportResult{}, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
return im.Import(ctx, f)
}
// Import streams the ADI content from r into the repo.
func (im *Importer) Import(ctx context.Context, r interface {
Read(p []byte) (int, error)
}) (ImportResult, error) {
if im.BatchSize <= 0 {
im.BatchSize = 500
}
res := ImportResult{}
batch := make([]qso.QSO, 0, im.BatchSize)
flush := func() error {
if len(batch) == 0 {
return nil
}
n, err := im.Repo.AddBatch(ctx, batch)
res.Imported += int(n)
batch = batch[:0]
return err
}
err := Parse(r, func(rec Record) error {
res.Total++
q, ok := recordToQSO(rec)
if !ok {
res.Skipped++
if len(res.Errors) < maxErrors {
res.Errors = append(res.Errors,
fmt.Sprintf("record %d: missing required fields (call/band/mode/date)", res.Total))
}
return nil
}
batch = append(batch, q)
if len(batch) >= im.BatchSize {
return flush()
}
return nil
})
if err != nil {
_ = flush()
return res, err
}
if err := flush(); err != nil {
return res, err
}
return res, nil
}
// adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
// Anything not in this set ends up in Extras.
var adifPromoted = stringSet(
// Core
"call", "qso_date", "time_on", "qso_date_off", "time_off",
"band", "band_rx", "mode", "submode", "freq", "freq_rx",
"rst_sent", "rst_rcvd",
// Contacted
"name", "qth", "address", "email", "web",
"gridsquare", "gridsquare_ext", "vucc_grids",
"country", "state", "cnty",
"dxcc", "cont", "cqz", "ituz",
"iota", "sota_ref", "pota_ref",
"age", "lat", "lon", "rig", "ant",
// QSL
"qsl_sent", "qsl_rcvd",
"qslsdate", "qslrdate", "qsl_via", "qslmsg", "qslmsg_rcvd",
"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
"clublog_qso_upload_date", "clublog_qso_upload_status",
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
// Contest
"contest_id", "srx", "stx", "srx_string", "stx_string",
"check", "precedence", "arrl_sect",
// Sat / propagation
"prop_mode", "sat_name", "sat_mode", "ant_az", "ant_el", "ant_path",
// My station
"station_callsign", "operator",
"my_gridsquare", "my_gridsquare_ext", "my_country", "my_state", "my_cnty", "my_iota",
"my_sota_ref", "my_pota_ref",
"my_dxcc", "my_cq_zone", "my_itu_zone", "my_lat", "my_lon",
"my_street", "my_city", "my_postal_code", "my_rig", "my_antenna",
// Misc
"tx_pwr", "comment", "notes",
)
func stringSet(items ...string) map[string]struct{} {
m := make(map[string]struct{}, len(items))
for _, s := range items {
m[s] = struct{}{}
}
return m
}
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
// fields are missing. Any ADIF tag we don't promote is stored in Extras.
func recordToQSO(rec Record) (qso.QSO, bool) {
call := strings.ToUpper(strings.TrimSpace(rec["call"]))
if call == "" {
return qso.QSO{}, false
}
band := strings.ToLower(strings.TrimSpace(rec["band"]))
mode := strings.ToUpper(strings.TrimSpace(rec["mode"]))
date := parseDateTime(rec["qso_date"], rec["time_on"])
if date.IsZero() || band == "" || mode == "" {
return qso.QSO{}, false
}
q := qso.QSO{
Callsign: call,
QSODate: date,
QSODateOff: parseDateTime(rec["qso_date_off"], rec["time_off"]),
Band: band,
BandRX: strings.ToLower(rec["band_rx"]),
Mode: mode,
Submode: strings.ToUpper(rec["submode"]),
}
if hz, ok := parseFreqHz(rec["freq"]); ok {
q.FreqHz = &hz
}
if hz, ok := parseFreqHz(rec["freq_rx"]); ok {
q.FreqRXHz = &hz
}
q.RSTSent = rec["rst_sent"]
q.RSTRcvd = rec["rst_rcvd"]
// Contacted station
q.Name = rec["name"]
q.QTH = rec["qth"]
q.Address = rec["address"]
q.Email = rec["email"]
q.Web = rec["web"]
q.Grid = strings.ToUpper(rec["gridsquare"])
q.GridExt = strings.ToUpper(rec["gridsquare_ext"])
q.VUCCGrids = strings.ToUpper(rec["vucc_grids"])
q.Country = rec["country"]
q.State = strings.ToUpper(rec["state"])
q.County = rec["cnty"]
if v, ok := parseInt(rec["dxcc"]); ok {
q.DXCC = &v
}
q.Continent = strings.ToUpper(rec["cont"])
if v, ok := parseInt(rec["cqz"]); ok {
q.CQZ = &v
}
if v, ok := parseInt(rec["ituz"]); ok {
q.ITUZ = &v
}
q.IOTA = strings.ToUpper(rec["iota"])
q.SOTARef = strings.ToUpper(rec["sota_ref"])
q.POTARef = strings.ToUpper(rec["pota_ref"])
if v, ok := parseInt(rec["age"]); ok {
q.Age = &v
}
if v, ok := parseFloat(rec["lat"]); ok {
q.Lat = &v
}
if v, ok := parseFloat(rec["lon"]); ok {
q.Lon = &v
}
q.Rig = rec["rig"]
q.Ant = rec["ant"]
// QSL
q.QSLSent = rec["qsl_sent"]
q.QSLRcvd = rec["qsl_rcvd"]
q.QSLSentDate = rec["qslsdate"]
q.QSLRcvdDate = rec["qslrdate"]
q.QSLVia = rec["qsl_via"]
q.QSLMsg = rec["qslmsg"]
q.QSLMsgRcvd = rec["qslmsg_rcvd"]
q.LOTWSent = rec["lotw_qsl_sent"]
q.LOTWRcvd = rec["lotw_qsl_rcvd"]
q.LOTWSentDate = rec["lotw_qslsdate"]
q.LOTWRcvdDate = rec["lotw_qslrdate"]
q.EQSLSent = rec["eqsl_qsl_sent"]
q.EQSLRcvd = rec["eqsl_qsl_rcvd"]
q.EQSLSentDate = rec["eqsl_qslsdate"]
q.EQSLRcvdDate = rec["eqsl_qslrdate"]
q.ClublogUploadDate = rec["clublog_qso_upload_date"]
q.ClublogUploadStatus = rec["clublog_qso_upload_status"]
q.HRDLogUploadDate = rec["hrdlog_qso_upload_date"]
q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
// Contest
q.ContestID = rec["contest_id"]
if v, ok := parseInt(rec["srx"]); ok {
q.SRX = &v
}
if v, ok := parseInt(rec["stx"]); ok {
q.STX = &v
}
q.SRXString = rec["srx_string"]
q.STXString = rec["stx_string"]
q.Check = rec["check"]
q.Precedence = rec["precedence"]
q.ARRLSect = strings.ToUpper(rec["arrl_sect"])
// Sat / propagation
q.PropMode = strings.ToUpper(rec["prop_mode"])
q.SatName = strings.ToUpper(rec["sat_name"])
q.SatMode = rec["sat_mode"]
if v, ok := parseFloat(rec["ant_az"]); ok {
q.AntAz = &v
}
if v, ok := parseFloat(rec["ant_el"]); ok {
q.AntEl = &v
}
q.AntPath = strings.ToUpper(rec["ant_path"])
// My station
q.StationCallsign = strings.ToUpper(rec["station_callsign"])
q.Operator = strings.ToUpper(rec["operator"])
q.MyGrid = strings.ToUpper(rec["my_gridsquare"])
q.MyGridExt = strings.ToUpper(rec["my_gridsquare_ext"])
q.MyCountry = rec["my_country"]
q.MyState = strings.ToUpper(rec["my_state"])
q.MyCounty = rec["my_cnty"]
q.MyIOTA = strings.ToUpper(rec["my_iota"])
q.MySOTARef = strings.ToUpper(rec["my_sota_ref"])
q.MyPOTARef = strings.ToUpper(rec["my_pota_ref"])
if v, ok := parseInt(rec["my_dxcc"]); ok {
q.MyDXCC = &v
}
if v, ok := parseInt(rec["my_cq_zone"]); ok {
q.MyCQZone = &v
}
if v, ok := parseInt(rec["my_itu_zone"]); ok {
q.MyITUZone = &v
}
if v, ok := parseFloat(rec["my_lat"]); ok {
q.MyLat = &v
}
if v, ok := parseFloat(rec["my_lon"]); ok {
q.MyLon = &v
}
q.MyStreet = rec["my_street"]
q.MyCity = rec["my_city"]
q.MyPostalCode = rec["my_postal_code"]
q.MyRig = rec["my_rig"]
q.MyAntenna = rec["my_antenna"]
// Misc
if v, ok := parseFloat(rec["tx_pwr"]); ok {
q.TXPower = &v
}
q.Comment = rec["comment"]
q.Notes = rec["notes"]
// Everything else lands in extras (uppercased ADIF names).
var extras map[string]string
for k, v := range rec {
if _, ok := adifPromoted[k]; ok {
continue
}
v = strings.TrimSpace(v)
if v == "" {
continue
}
if extras == nil {
extras = map[string]string{}
}
extras[strings.ToUpper(k)] = v
}
q.Extras = extras
return q, true
}
// parseDateTime combines ADIF QSO_DATE (YYYYMMDD) with TIME (HHMMSS or HHMM).
func parseDateTime(date, timeStr string) time.Time {
date = strings.TrimSpace(date)
timeStr = strings.TrimSpace(timeStr)
if len(date) != 8 {
return time.Time{}
}
layout := "20060102"
val := date
if len(timeStr) == 4 {
layout = "200601021504"
val = date + timeStr
} else if len(timeStr) == 6 {
layout = "20060102150405"
val = date + timeStr
}
t, err := time.ParseInLocation(layout, val, time.UTC)
if err != nil {
return time.Time{}
}
return t.UTC()
}
func parseFreqHz(s string) (int64, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
mhz, err := strconv.ParseFloat(s, 64)
if err != nil || mhz <= 0 {
return 0, false
}
return int64(mhz*1_000_000 + 0.5), true
}
func parseInt(s string) (int, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
v, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return v, true
}
func parseFloat(s string) (float64, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return v, true
}
+115
View File
@@ -0,0 +1,115 @@
// Package adif handles ADIF import and export (ADI text format).
//
// ADI tokenisation rules (per ADIF spec):
// - Free-form text is allowed up to the first <EOH> (header end).
// - After <EOH>, records are sequences of <FIELDNAME:LENGTH[:TYPE]>VALUE
// terminated by <EOR>.
// - The LENGTH is the byte count of the VALUE that immediately follows
// the closing '>' (no separator).
// - Tag names are case-insensitive.
// - Bytes between fields (whitespace, junk) are ignored.
package adif
import (
"bufio"
"fmt"
"io"
"strconv"
"strings"
)
// Record is a single ADIF record. Keys are lowercased field names.
type Record map[string]string
// Parse reads an ADI stream and invokes fn for each record (after <EOH>).
// Returning a non-nil error from fn stops parsing and is propagated.
// The header (text before <EOH>) is silently discarded.
func Parse(r io.Reader, fn func(Record) error) error {
br := bufio.NewReaderSize(r, 64*1024)
rec := Record{}
headerDone := false
for {
// Seek next '<'. Bytes before it are either header text or
// inter-field whitespace — both discardable.
if err := seekByte(br, '<'); err != nil {
if err == io.EOF {
return nil
}
return err
}
spec, err := readUntilByte(br, '>')
if err != nil {
if err == io.EOF {
return nil
}
return fmt.Errorf("unterminated tag: %w", err)
}
name, length := parseSpec(spec)
switch name {
case "eoh":
headerDone = true
rec = Record{}
continue
case "eor":
if headerDone && len(rec) > 0 {
if err := fn(rec); err != nil {
return err
}
}
rec = Record{}
continue
}
// Skip value bytes regardless of header state; we only emit
// records once we've crossed <EOH>.
if length > 0 {
val := make([]byte, length)
if _, err := io.ReadFull(br, val); err != nil {
return fmt.Errorf("read field %s: %w", name, err)
}
if headerDone && name != "" {
rec[name] = string(val)
}
}
}
}
// parseSpec splits "callsign:5", "callsign:5:S" or "eor" into name and length.
// name is lowercased; length is 0 for control tags or when missing.
func parseSpec(spec string) (name string, length int) {
parts := strings.SplitN(strings.TrimSpace(spec), ":", 3)
name = strings.ToLower(strings.TrimSpace(parts[0]))
if len(parts) >= 2 {
if n, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil && n > 0 {
length = n
}
}
return
}
func seekByte(br *bufio.Reader, target byte) error {
for {
b, err := br.ReadByte()
if err != nil {
return err
}
if b == target {
return nil
}
}
}
func readUntilByte(br *bufio.Reader, target byte) (string, error) {
var sb strings.Builder
for {
b, err := br.ReadByte()
if err != nil {
return sb.String(), err
}
if b == target {
return sb.String(), nil
}
sb.WriteByte(b)
}
}
+84
View File
@@ -0,0 +1,84 @@
package adif
import (
"strings"
"testing"
)
func TestParseSimple(t *testing.T) {
src := `Header text here
Generated by HamLog
<EOH>
<CALL:5>F4XYZ<BAND:3>20m<MODE:3>SSB<QSO_DATE:8>20240101<TIME_ON:6>123456<EOR>
<call:4>K1AB<band:3>40m<mode:2>CW<qso_date:8>20240102<time_on:4>0930<eor>
`
var got []Record
err := Parse(strings.NewReader(src), func(r Record) error {
got = append(got, r)
return nil
})
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(got) != 2 {
t.Fatalf("want 2 records, got %d", len(got))
}
if got[0]["call"] != "F4XYZ" || got[0]["band"] != "20m" || got[0]["mode"] != "SSB" {
t.Errorf("record 0 mismatch: %+v", got[0])
}
if got[1]["call"] != "K1AB" || got[1]["time_on"] != "0930" {
t.Errorf("record 1 mismatch: %+v", got[1])
}
}
func TestParseValueWithAngleBracket(t *testing.T) {
// Length-prefixed value can contain '<' and '>' bytes.
src := `<EOH><CALL:5>F4XYZ<COMMENT:7>a<b>c<d<EOR>`
var got []Record
err := Parse(strings.NewReader(src), func(r Record) error {
got = append(got, r)
return nil
})
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(got) != 1 {
t.Fatalf("want 1, got %d", len(got))
}
if got[0]["comment"] != "a<b>c<d" {
t.Errorf("comment mismatch: %q", got[0]["comment"])
}
}
func TestParseNoHeader(t *testing.T) {
// Some loggers omit the header entirely — records before <EOH> are
// discarded by design. Verify nothing is emitted in that case.
src := `<CALL:5>F4XYZ<EOR>`
var got int
err := Parse(strings.NewReader(src), func(r Record) error {
got++
return nil
})
if err != nil {
t.Fatalf("parse: %v", err)
}
if got != 0 {
t.Errorf("expected 0 records without <EOH>, got %d", got)
}
}
func TestParseTypedField(t *testing.T) {
// <FIELD:LEN:TYPE> form (e.g. <FREQ:6:N>).
src := `<EOH><CALL:5>F4XYZ<FREQ:6:N>14.250<EOR>`
var got Record
err := Parse(strings.NewReader(src), func(r Record) error {
got = r
return nil
})
if err != nil {
t.Fatalf("parse: %v", err)
}
if got["freq"] != "14.250" {
t.Errorf("freq mismatch: %q", got["freq"])
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package antenna drives antennas (Ultrabeam in particular).
// TODO: implementation.
package antenna
+4
View File
@@ -0,0 +1,4 @@
// Package api exposes a small local HTTP REST server that lets third-party
// tools push a callsign (or a full QSO) into the logbook.
// TODO: implementation.
package api
+5
View File
@@ -0,0 +1,5 @@
// Package award implements the awards engine (DXCC, WAS, WAZ, IOTA, SOTA, …)
// and a rule system letting the user define custom awards
// (matching on band/mode/country/grid/etc.).
// TODO: implementation.
package award
+322
View File
@@ -0,0 +1,322 @@
// Package cat drives the transceiver via swappable backends (OmniRig, Flex…)
// and pushes state changes to the UI through an injected emitter callback.
//
// The poll loop runs on an OS-thread-locked goroutine so COM-based backends
// (OmniRig) work correctly — COM is thread-affine on Windows and must be
// initialised, used and uninitialised from the same OS thread.
package cat
import (
"fmt"
"runtime"
"sync"
"time"
)
// Backend abstracts a specific transceiver-control library. All methods run
// on the dedicated CAT goroutine spawned by Manager — implementations can
// assume single-threaded access and can safely manage thread-bound resources
// (e.g. COM objects in OmniRig).
type Backend interface {
Name() string // "omnirig" | "flex" | …
Connect() error
Disconnect()
ReadState() (RigState, error)
SetFrequency(hz int64) error
// SetMode receives an ADIF mode string (SSB, CW, FT8, RTTY, AM, FM…).
// Implementations decide USB vs LSB (typically by current freq) and
// generic vs specific digital modes (most rigs just have DATA).
SetMode(mode string) error
}
// RigState is the snapshot exchanged with the frontend.
//
// FreqHz follows the ADIF FREQ convention: it is the TX frequency. When the
// rig is in split, FreqHz is the inactive VFO (where the operator transmits)
// and RxFreqHz is the active VFO (where they listen). When not split,
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
type RigState struct {
Enabled bool `json:"enabled"` // user toggled CAT on
Connected bool `json:"connected"` // backend says rig is online
Backend string `json:"backend,omitempty"` // active backend name
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
Rig string `json:"rig,omitempty"` // rig model (best-effort)
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
Split bool `json:"split,omitempty"` // rig is in split mode
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
Band string `json:"band,omitempty"` // computed from FreqHz
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
Error string `json:"error,omitempty"` // last connect/poll error if any
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// Manager owns the active backend and runs the polling loop.
type Manager struct {
mu sync.RWMutex
state RigState
emit func(RigState)
backend Backend
// Set when running. nil when stopped.
stopCh chan struct{}
doneCh chan struct{}
cmdCh chan func() // marshall arbitrary work onto the CAT goroutine
pollEvery time.Duration
cmdDelay time.Duration // pause after each command (some rigs need it)
}
func NewManager(emit func(RigState)) *Manager {
return &Manager{emit: emit, pollEvery: 250 * time.Millisecond}
}
// SetPollInterval changes the polling cadence. Caps at 50ms…2s to avoid
// either hammering the rig or feeling laggy.
func (m *Manager) SetPollInterval(d time.Duration) {
if d < 50*time.Millisecond {
d = 50 * time.Millisecond
}
if d > 2*time.Second {
d = 2 * time.Second
}
m.mu.Lock()
m.pollEvery = d
m.mu.Unlock()
}
// SetCommandDelay sets a pause inserted after each CAT command. Some older
// Kenwood/Yaesu rigs drop bytes if commands arrive too fast back to back.
// Capped at 0…500ms — beyond that, fix your rig.
func (m *Manager) SetCommandDelay(d time.Duration) {
if d < 0 {
d = 0
}
if d > 500*time.Millisecond {
d = 500 * time.Millisecond
}
m.mu.Lock()
m.cmdDelay = d
m.mu.Unlock()
}
// State returns a copy of the latest known state.
func (m *Manager) State() RigState {
m.mu.RLock()
defer m.mu.RUnlock()
return m.state
}
// Start spins up the CAT goroutine with the given backend. If a backend is
// already running it is stopped first. Errors during Connect are surfaced as
// state.Error rather than returned, so the UI can keep retrying via the
// poll loop on next reconnect attempt.
func (m *Manager) Start(b Backend) {
m.Stop()
m.mu.Lock()
m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{})
m.cmdCh = make(chan func(), 4)
m.backend = b
stop := m.stopCh
done := m.doneCh
cmds := m.cmdCh
poll := m.pollEvery
m.state = RigState{Enabled: true, Backend: b.Name()}
m.mu.Unlock()
m.emitState()
go m.run(b, stop, done, cmds, poll)
}
// Stop signals the CAT goroutine to disconnect and waits for it to exit.
func (m *Manager) Stop() {
m.mu.Lock()
stop := m.stopCh
done := m.doneCh
m.stopCh = nil
m.doneCh = nil
m.cmdCh = nil
m.backend = nil
m.mu.Unlock()
if stop != nil {
close(stop)
}
if done != nil {
<-done
}
m.mu.Lock()
m.state = RigState{Enabled: false}
m.mu.Unlock()
m.emitState()
}
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
func (m *Manager) SetFrequency(hz int64) error {
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
}
// SetMode dispatches a SetMode call to the CAT goroutine.
func (m *Manager) SetMode(mode string) error {
return m.exec(func(b Backend) error { return b.SetMode(mode) })
}
// exec marshals a backend operation onto the CAT goroutine. Returns the
// operation's error or a "busy"/"not running" error if dispatch failed.
func (m *Manager) exec(fn func(Backend) error) error {
m.mu.RLock()
cmds := m.cmdCh
b := m.backend
m.mu.RUnlock()
if cmds == nil || b == nil {
return fmt.Errorf("cat not running")
}
errCh := make(chan error, 1)
select {
case cmds <- func() { errCh <- fn(b) }:
case <-time.After(500 * time.Millisecond):
return fmt.Errorf("cat busy")
}
return <-errCh
}
// run is the CAT goroutine. Owns the backend lifecycle.
func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pollEvery time.Duration) {
// Lock to a single OS thread — required for COM. Cheap for non-COM backends.
runtime.LockOSThread()
defer runtime.UnlockOSThread()
defer close(done)
if err := b.Connect(); err != nil {
m.update(RigState{
Enabled: true, Backend: b.Name(), Connected: false,
Error: err.Error(), UpdatedAt: time.Now(),
})
// Stay idle until Stop is called — let the user fix config and re-Start.
for {
select {
case <-stop:
return
case fn := <-cmds:
fn()
}
}
}
defer b.Disconnect()
ticker := time.NewTicker(pollEvery)
defer ticker.Stop()
for {
select {
case <-stop:
return
case fn := <-cmds:
fn()
m.applyCommandDelay()
case <-ticker.C:
ns, err := b.ReadState()
if err != nil {
m.update(RigState{
Enabled: true, Backend: b.Name(), Connected: false,
Error: err.Error(), UpdatedAt: time.Now(),
})
continue
}
ns.Enabled = true
ns.Backend = b.Name()
ns.UpdatedAt = time.Now()
if ns.FreqHz != 0 && ns.Band == "" {
ns.Band = BandFromHz(ns.FreqHz)
}
m.update(ns)
}
}
}
func (m *Manager) applyCommandDelay() {
m.mu.RLock()
d := m.cmdDelay
m.mu.RUnlock()
if d > 0 {
time.Sleep(d)
}
}
// update stores the new state and emits an event ONLY if something changed
// that the UI cares about — avoids flooding the event bus 4x per second.
func (m *Manager) update(ns RigState) {
m.mu.Lock()
changed := !stateUserEqual(m.state, ns)
m.state = ns
m.mu.Unlock()
if changed {
m.emitState()
}
}
func (m *Manager) emitState() {
if m.emit == nil {
return
}
m.emit(m.State())
}
func stateUserEqual(a, b RigState) bool {
return a.Enabled == b.Enabled &&
a.Connected == b.Connected &&
a.Backend == b.Backend &&
a.RigNum == b.RigNum &&
a.Rig == b.Rig &&
a.FreqHz == b.FreqHz &&
a.RxFreqHz == b.RxFreqHz &&
a.Split == b.Split &&
a.Mode == b.Mode &&
a.Vfo == b.Vfo &&
a.Band == b.Band &&
a.Error == b.Error
}
// BandFromHz returns the ADIF band tag covering the given frequency, or "".
// Ranges follow IARU/ITU plans. 60m is treated as a single block for
// simplicity — channelised access varies by region.
func BandFromHz(hz int64) string {
mhz := float64(hz) / 1_000_000
switch {
case mhz >= 1.8 && mhz <= 2.0:
return "160m"
case mhz >= 3.5 && mhz <= 4.0:
return "80m"
case mhz >= 5.3 && mhz <= 5.5:
return "60m"
case mhz >= 7.0 && mhz <= 7.3:
return "40m"
case mhz >= 10.1 && mhz <= 10.15:
return "30m"
case mhz >= 14.0 && mhz <= 14.35:
return "20m"
case mhz >= 18.068 && mhz <= 18.168:
return "17m"
case mhz >= 21.0 && mhz <= 21.45:
return "15m"
case mhz >= 24.89 && mhz <= 24.99:
return "12m"
case mhz >= 28.0 && mhz <= 29.7:
return "10m"
case mhz >= 50.0 && mhz <= 54.0:
return "6m"
case mhz >= 70.0 && mhz <= 70.5:
return "4m"
case mhz >= 144.0 && mhz <= 148.0:
return "2m"
case mhz >= 222.0 && mhz <= 225.0:
return "1.25m"
case mhz >= 420.0 && mhz <= 450.0:
return "70cm"
case mhz >= 902.0 && mhz <= 928.0:
return "33cm"
case mhz >= 1240.0 && mhz <= 1300.0:
return "23cm"
}
return ""
}
+42
View File
@@ -0,0 +1,42 @@
package cat
import (
"log"
"os"
"path/filepath"
)
// debugLog writes CAT debug events to %APPDATA%/HamLog/cat.log so users can
// diagnose mode/freq mismatches without rebuilding with -windowsconsole.
//
// Initialised lazily on first use. Falls back to the standard library
// default logger (stderr, usually invisible in a Wails GUI build) if the
// log file can't be opened.
var debugLog = openDebugLog()
func openDebugLog() *log.Logger {
base, err := os.UserConfigDir()
if err != nil {
return log.Default()
}
dir := filepath.Join(base, "HamLog")
if err := os.MkdirAll(dir, 0o755); err != nil {
return log.Default()
}
f, err := os.OpenFile(filepath.Join(dir, "cat.log"),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return log.Default()
}
return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
}
// DebugLogPath returns the path the cat.log file would be opened at, for
// surfacing in the UI / docs.
func DebugLogPath() string {
base, err := os.UserConfigDir()
if err != nil {
return ""
}
return filepath.Join(base, "HamLog", "cat.log")
}
+321
View File
@@ -0,0 +1,321 @@
package cat
import (
"fmt"
"strings"
"github.com/go-ole/go-ole"
"github.com/go-ole/go-ole/oleutil"
)
// OmniRig talks to the user's installed OmniRig server over COM.
//
// All methods MUST be called from the same OS thread (the one Manager.run
// locks). COM is thread-affine on Windows — calling these from random
// goroutines will return E_FAIL or crash.
//
// The user must install OmniRig separately and configure their rig (COM port,
// baud rate) in OmniRig's own GUI. HamLog just reads/writes through it.
type OmniRig struct {
RigNum int // 1 (Rig1) or 2 (Rig2)
omnirig *ole.IDispatch
rig *ole.IDispatch
}
// NewOmniRig creates a non-connected backend. Call Connect before use.
func NewOmniRig(rigNum int) *OmniRig {
if rigNum < 1 || rigNum > 2 {
rigNum = 1
}
return &OmniRig{RigNum: rigNum}
}
func (o *OmniRig) Name() string { return "omnirig" }
func (o *OmniRig) Connect() error {
debugLog.Printf("OmniRig.Connect Rig%d — log path: %s", o.RigNum, DebugLogPath())
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
// 0x1 = S_FALSE → COM already initialised on this thread, fine.
if oerr, ok := err.(*ole.OleError); !ok || oerr.Code() != 0x00000001 {
return fmt.Errorf("CoInitializeEx: %w", err)
}
}
unk, err := oleutil.CreateObject("Omnirig.OmnirigX")
if err != nil {
return fmt.Errorf("Omnirig.OmnirigX not available — is OmniRig installed and running?: %w", err)
}
omnirig, err := unk.QueryInterface(ole.IID_IDispatch)
unk.Release()
if err != nil {
return fmt.Errorf("query interface: %w", err)
}
rigVar, err := oleutil.GetProperty(omnirig, fmt.Sprintf("Rig%d", o.RigNum))
if err != nil {
omnirig.Release()
return fmt.Errorf("get Rig%d: %w", o.RigNum, err)
}
o.omnirig = omnirig
o.rig = rigVar.ToIDispatch()
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
}
return nil
}
func (o *OmniRig) Disconnect() {
if o.rig != nil {
o.rig.Release()
o.rig = nil
}
if o.omnirig != nil {
o.omnirig.Release()
o.omnirig = nil
}
ole.CoUninitialize()
}
func (o *OmniRig) ReadState() (RigState, error) {
if o.rig == nil {
return RigState{}, fmt.Errorf("not connected")
}
var s RigState
s.Backend = o.Name()
s.RigNum = o.RigNum
// Status: 0 = NOTCONFIGURED, 1 = DISABLED, 2 = PORTBUSY,
// 3 = NOTRESPONDING, 4 = ONLINE.
if statusVar, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
s.Connected = statusVar.Val == 4
}
if rigTypeVar, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
s.Rig = rigTypeVar.ToString()
}
if !s.Connected {
// Status string from OmniRig is informative for the user.
if statusStrVar, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
s.Error = statusStrVar.ToString()
}
return s, nil
}
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
s.Mode = omniRigMode(modeVar.Val)
}
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
s.Vfo = omniRigVfo(vfoVar.Val)
}
// Read both VFO frequencies separately so we can expose split TX/RX.
// Fall back to generic Freq if the rig only exposes the merged property.
freqA, freqB := int64(0), int64(0)
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
freqA = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "FreqB"); err == nil {
freqB = v.Val
}
// Split detection: trust the explicit Split property when it's set,
// BUT only call it a real split if both VFO frequencies are non-zero
// and distinct. Bridges like SmartSDR-OmniRig report Split=ON by
// default (raw bit PM_SPLITON = 65536) with FreqB=0 because the Flex's
// slice model doesn't map to VFO A/B — that would yield a useless
// permanent SPLIT badge.
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil && v.Val != 0 {
s.Split = true
}
if s.Split && (freqB == 0 || freqA == freqB) {
s.Split = false
s.RxFreqHz = 0
}
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
// We follow ADIF: FreqHz = TX, RxFreqHz = RX (only meaningful in split).
switch s.Vfo {
case "AB":
s.FreqHz = freqB // TX
s.RxFreqHz = freqA // RX
case "BA":
s.FreqHz = freqA // TX
s.RxFreqHz = freqB // RX
case "B", "BB":
s.FreqHz = freqB
default: // "A", "AA", "" — single VFO on A or unknown
s.FreqHz = freqA
}
if s.FreqHz == 0 {
// Last resort — some rigs only update generic Freq.
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
s.FreqHz = v.Val
}
}
return s, nil
}
func (o *OmniRig) SetFrequency(hz int64) error {
if o.rig == nil {
return fmt.Errorf("not connected")
}
// OmniRig Freq is a Long (int32). Validate to avoid silent truncation.
if hz < 0 || hz > 0x7fffffff {
return fmt.Errorf("frequency out of OmniRig int32 range")
}
hz32 := int32(hz)
// Pick the right OmniRig property. Many rig .ini files only define a
// WRITE command for FreqA/FreqB but not the generic Freq — in which case
// PutProperty(Freq) silently succeeds but the rig never moves. Write to
// the active VFO's specific property when we know it; fall back to Freq.
prop := "FreqA"
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
switch omniRigVfo(vfoVar.Val) {
case "B", "BB", "BA":
prop = "FreqB"
case "A", "AA", "AB":
prop = "FreqA"
}
}
debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz) → %s", hz, float64(hz)/1e6, prop)
if _, err := oleutil.PutProperty(o.rig, prop, hz32); err != nil {
debugLog.Printf("OmniRig.SetFrequency(%s) error: %v — falling back to Freq", prop, err)
if _, err2 := oleutil.PutProperty(o.rig, "Freq", hz32); err2 != nil {
debugLog.Printf("OmniRig.SetFrequency(Freq) also failed: %v", err2)
return err2
}
}
// Read back the active VFO freq after a short delay so the log shows
// whether the rig actually moved. Useful when the .ini accepts the write
// silently but the rig doesn't honour it (wrong WRITE command etc.).
if fv, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
debugLog.Printf("OmniRig.Freq immediately after Put = %d Hz", fv.Val)
}
return nil
}
// SetMode maps an ADIF mode to the OmniRig PM_* bit and pushes it to the rig.
// For SSB, the USB/LSB side is chosen from the rig's current frequency
// following worldwide convention (LSB below 14 MHz, USB above).
//
// IMPORTANT: OmniRig's Mode property is typed as Long (VT_I4). go-ole would
// otherwise wrap a Go int64 into a VT_I8 variant which COM marshalling can
// reject silently or misinterpret — passing the wrong bit. Always cast to
// int32 explicitly.
//
// Logs each call to stdout so the user can cross-check what HamLog sent
// against OmniRig's Monitor window (right-click systray → Monitor) to find
// rig-specific mismatches (e.g. a Kenwood without FM on HF, an .ini with the
// wrong CAT command for a mode, etc.).
func (o *OmniRig) SetMode(mode string) error {
if o.rig == nil {
return fmt.Errorf("not connected")
}
var (
bit int64
bitName string
)
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW":
bit, bitName = pmCWU, "PM_CW_U"
case "SSB":
// Read current freq to decide USB vs LSB.
var freq int64
if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
freq = freqVar.Val
}
if freq > 0 && freq < 10_000_000 {
bit, bitName = pmSSBL, "PM_SSB_L"
} else {
bit, bitName = pmSSBU, "PM_SSB_U"
}
case "AM":
bit, bitName = pmAM, "PM_AM"
case "FM":
bit, bitName = pmFM, "PM_FM"
case "RTTY", "FSK":
// OmniRig has no specific RTTY/FSK mode — falls back to generic
// digital USB. Many rigs need RTTY selected manually on the panel.
bit, bitName = pmDIGU, "PM_DIG_U"
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DIGITALVOICE", "DATA":
bit, bitName = pmDIGU, "PM_DIG_U"
default:
return fmt.Errorf("OmniRig: unsupported mode %q", mode)
}
debugLog.Printf("OmniRig.SetMode(%q) → %s = 0x%08X (%d)", mode, bitName, bit, bit)
_, err := oleutil.PutProperty(o.rig, "Mode", int32(bit))
if err != nil {
debugLog.Printf("OmniRig.SetMode error: %v", err)
return fmt.Errorf("SetMode(%s) → %s: %w", mode, bitName, err)
}
// Read back what OmniRig now thinks the rig is on (best-effort —
// OmniRig is async so this may still be the old value for one poll).
if mv, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
debugLog.Printf("OmniRig.Mode immediately after Put = 0x%08X (%d) → %s",
mv.Val, mv.Val, omniRigMode(mv.Val))
}
return nil
}
// ===== OmniRig enum decoders =====
// Bit flags from OmniRig type library (RigParamX enum in OmniRig_TLB.pas).
//
// Cross-checked against https://github.com/VE3NEA/OmniRig — be careful when
// referencing other people's writeups online, several have these one bit
// too low which causes every mode to map to the slot below it (AM → DIG_L,
// FT8 → SSB_L, etc.).
const (
pmCWU int64 = 1 << 23 // 0x00800000
pmCWL int64 = 1 << 24 // 0x01000000
pmSSBU int64 = 1 << 25 // 0x02000000
pmSSBL int64 = 1 << 26 // 0x04000000
pmDIGU int64 = 1 << 27 // 0x08000000
pmDIGL int64 = 1 << 28 // 0x10000000
pmAM int64 = 1 << 29 // 0x20000000
pmFM int64 = 1 << 30 // 0x40000000 — still fits in int32 (max 2^31-1)
)
// omniRigMode maps the OmniRig Mode bit-flag to an ADIF mode string.
// OmniRig only reports rough categories; specific digital modes
// (FT8, RTTY, PSK31…) can't be inferred — DATA is returned and the user
// can keep / override the mode they already had in the entry form.
func omniRigMode(m int64) string {
switch {
case m&(pmCWU|pmCWL) != 0:
return "CW"
case m&(pmSSBU|pmSSBL) != 0:
return "SSB"
case m&(pmDIGU|pmDIGL) != 0:
return "DATA"
case m&pmAM != 0:
return "AM"
case m&pmFM != 0:
return "FM"
}
return ""
}
func omniRigVfo(v int64) string {
switch {
case v&1024 != 0:
return "A"
case v&2048 != 0:
return "B"
case v&64 != 0:
return "AA"
case v&128 != 0:
return "AB"
case v&256 != 0:
return "BA"
case v&512 != 0:
return "BB"
}
return ""
}
+4
View File
@@ -0,0 +1,4 @@
// Package cluster provides a DX cluster client (telnet) with filters
// (band, mode, callsign, continent, ITU/CQ, …).
// TODO: implementation.
package cluster
+90
View File
@@ -0,0 +1,90 @@
// Package db handles the SQLite connection and migrations.
package db
import (
"database/sql"
"embed"
"fmt"
"sort"
"strings"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Open opens (and creates if needed) the SQLite database at the given path,
// enables performance PRAGMAs, and applies embedded migrations.
func Open(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(on)&_pragma=synchronous(normal)&_pragma=busy_timeout(5000)", path)
conn, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := conn.Ping(); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
if err := migrate(conn); err != nil {
_ = conn.Close()
return nil, err
}
return conn, nil
}
// migrate applies all embedded *.sql migrations in alphabetical order,
// skipping those already applied. Intentionally minimal in-house system
// (no external dependency).
func migrate(conn *sql.DB) error {
if _, err := conn.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
names = append(names, e.Name())
}
sort.Strings(names)
for _, name := range names {
var dummy string
err := conn.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&dummy)
if err == nil {
continue // already applied
}
if err != sql.ErrNoRows {
return fmt.Errorf("check migration %s: %w", name, err)
}
content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
tx, err := conn.Begin()
if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err)
}
if _, err := tx.Exec(string(content)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations(name) VALUES(?)`, name); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
-- HamLog initial schema
-- QSO table: core of the logbook. Field names stay close to ADIF.
CREATE TABLE IF NOT EXISTS qso (
id INTEGER PRIMARY KEY AUTOINCREMENT,
callsign TEXT NOT NULL,
qso_date TEXT NOT NULL, -- ISO 8601 UTC: YYYY-MM-DDTHH:MM:SSZ
band TEXT NOT NULL, -- e.g. 20m, 40m, 2m
mode TEXT NOT NULL, -- e.g. SSB, CW, FT8
freq_hz INTEGER, -- frequency in Hz (integer, avoids floats)
rst_sent TEXT,
rst_rcvd TEXT,
name TEXT,
qth TEXT,
grid TEXT,
country TEXT,
dxcc INTEGER,
cont TEXT,
cqz INTEGER,
ituz INTEGER,
iota TEXT,
sota_ref TEXT,
pota_ref TEXT,
-- Operator context (multi-callsign / multi-location)
station_callsign TEXT,
operator TEXT,
my_grid TEXT,
my_country TEXT,
my_sota_ref TEXT,
my_pota_ref TEXT,
-- Misc
tx_pwr REAL,
comment TEXT,
notes TEXT,
qsl_sent TEXT DEFAULT 'N',
qsl_rcvd TEXT DEFAULT 'N',
lotw_sent TEXT DEFAULT 'N',
lotw_rcvd TEXT DEFAULT 'N',
eqsl_sent TEXT DEFAULT 'N',
eqsl_rcvd TEXT DEFAULT 'N',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_qso_callsign ON qso(callsign);
CREATE INDEX IF NOT EXISTS idx_qso_date ON qso(qso_date DESC);
CREATE INDEX IF NOT EXISTS idx_qso_band_mode ON qso(band, mode);
CREATE INDEX IF NOT EXISTS idx_qso_dxcc ON qso(dxcc);
CREATE INDEX IF NOT EXISTS idx_qso_grid ON qso(grid);
CREATE INDEX IF NOT EXISTS idx_qso_station ON qso(station_callsign);
+21
View File
@@ -0,0 +1,21 @@
-- Key/value app settings (lookup credentials, preferences, …)
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
-- Local cache of QRZ/HamQTH lookups to avoid re-querying for the same call.
CREATE TABLE IF NOT EXISTS callsign_cache (
callsign TEXT PRIMARY KEY,
name TEXT,
qth TEXT,
country TEXT,
grid TEXT,
dxcc INTEGER,
cqz INTEGER,
ituz INTEGER,
cont TEXT,
source TEXT NOT NULL, -- 'qrz' | 'hamqth'
fetched_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
@@ -0,0 +1,81 @@
-- Expand the QSO table to cover the rest of the common ADIF fields.
-- SQLite ALTER TABLE ADD COLUMN is metadata-only, so this is fast even on
-- large logbooks. Anything not promoted to a column lives in extras_json.
-- --- Times / frequencies / mode ---
ALTER TABLE qso ADD COLUMN qso_date_off TEXT; -- ISO UTC end datetime
ALTER TABLE qso ADD COLUMN freq_rx_hz INTEGER; -- RX frequency for split operation
ALTER TABLE qso ADD COLUMN band_rx TEXT;
ALTER TABLE qso ADD COLUMN submode TEXT; -- USB, LSB, USB-DATA, ...
-- --- Contacted station extras ---
ALTER TABLE qso ADD COLUMN state TEXT; -- US state, JA prefecture, etc.
ALTER TABLE qso ADD COLUMN cnty TEXT;
ALTER TABLE qso ADD COLUMN address TEXT;
ALTER TABLE qso ADD COLUMN email TEXT;
ALTER TABLE qso ADD COLUMN web TEXT;
ALTER TABLE qso ADD COLUMN age INTEGER;
ALTER TABLE qso ADD COLUMN lat REAL;
ALTER TABLE qso ADD COLUMN lon REAL;
ALTER TABLE qso ADD COLUMN gridsquare_ext TEXT; -- 8/10-char extension
ALTER TABLE qso ADD COLUMN vucc_grids TEXT;
ALTER TABLE qso ADD COLUMN rig TEXT; -- contacted station's rig
ALTER TABLE qso ADD COLUMN ant TEXT; -- contacted station's antenna
-- --- QSL bureau / direct / LoTW / eQSL / Clublog / HRDLog ---
ALTER TABLE qso ADD COLUMN qsl_via TEXT;
ALTER TABLE qso ADD COLUMN qsl_msg TEXT;
ALTER TABLE qso ADD COLUMN qslmsg_rcvd TEXT;
ALTER TABLE qso ADD COLUMN qsl_sent_date TEXT;
ALTER TABLE qso ADD COLUMN qsl_rcvd_date TEXT;
ALTER TABLE qso ADD COLUMN lotw_sent_date TEXT;
ALTER TABLE qso ADD COLUMN lotw_rcvd_date TEXT;
ALTER TABLE qso ADD COLUMN eqsl_sent_date TEXT;
ALTER TABLE qso ADD COLUMN eqsl_rcvd_date TEXT;
ALTER TABLE qso ADD COLUMN clublog_qso_upload_date TEXT;
ALTER TABLE qso ADD COLUMN clublog_qso_upload_status TEXT;
ALTER TABLE qso ADD COLUMN hrdlog_qso_upload_date TEXT;
ALTER TABLE qso ADD COLUMN hrdlog_qso_upload_status TEXT;
-- --- Contest ---
ALTER TABLE qso ADD COLUMN contest_id TEXT;
ALTER TABLE qso ADD COLUMN srx INTEGER;
ALTER TABLE qso ADD COLUMN stx INTEGER;
ALTER TABLE qso ADD COLUMN srx_string TEXT;
ALTER TABLE qso ADD COLUMN stx_string TEXT;
ALTER TABLE qso ADD COLUMN check_field TEXT; -- ADIF CHECK (reserved word in SQL)
ALTER TABLE qso ADD COLUMN precedence TEXT;
ALTER TABLE qso ADD COLUMN arrl_sect TEXT;
-- --- Satellite / propagation ---
ALTER TABLE qso ADD COLUMN prop_mode TEXT;
ALTER TABLE qso ADD COLUMN sat_name TEXT;
ALTER TABLE qso ADD COLUMN sat_mode TEXT;
ALTER TABLE qso ADD COLUMN ant_az REAL;
ALTER TABLE qso ADD COLUMN ant_el REAL;
ALTER TABLE qso ADD COLUMN ant_path TEXT;
-- --- My station extras (per-QSO overrides of the active profile) ---
ALTER TABLE qso ADD COLUMN my_state TEXT;
ALTER TABLE qso ADD COLUMN my_cnty TEXT;
ALTER TABLE qso ADD COLUMN my_iota TEXT;
ALTER TABLE qso ADD COLUMN my_dxcc INTEGER;
ALTER TABLE qso ADD COLUMN my_cq_zone INTEGER;
ALTER TABLE qso ADD COLUMN my_itu_zone INTEGER;
ALTER TABLE qso ADD COLUMN my_lat REAL;
ALTER TABLE qso ADD COLUMN my_lon REAL;
ALTER TABLE qso ADD COLUMN my_street TEXT;
ALTER TABLE qso ADD COLUMN my_city TEXT;
ALTER TABLE qso ADD COLUMN my_postal_code TEXT;
ALTER TABLE qso ADD COLUMN my_rig TEXT;
ALTER TABLE qso ADD COLUMN my_antenna TEXT;
ALTER TABLE qso ADD COLUMN my_gridsquare_ext TEXT;
-- --- Catch-all for ADIF fields we don't promote to columns ---
-- JSON object: { "FIELD_NAME": "value", ... } (keys uppercase as in ADIF).
ALTER TABLE qso ADD COLUMN extras_json TEXT;
CREATE INDEX IF NOT EXISTS idx_qso_state ON qso(state);
CREATE INDEX IF NOT EXISTS idx_qso_contest_id ON qso(contest_id);
CREATE INDEX IF NOT EXISTS idx_qso_sat_name ON qso(sat_name);
CREATE INDEX IF NOT EXISTS idx_qso_prop_mode ON qso(prop_mode);
@@ -0,0 +1,8 @@
-- Extra columns captured from QRZ/HamQTH lookups, mapped onto F2 Info fields.
ALTER TABLE callsign_cache ADD COLUMN address TEXT;
ALTER TABLE callsign_cache ADD COLUMN state TEXT;
ALTER TABLE callsign_cache ADD COLUMN cnty TEXT;
ALTER TABLE callsign_cache ADD COLUMN lat REAL;
ALTER TABLE callsign_cache ADD COLUMN lon REAL;
ALTER TABLE callsign_cache ADD COLUMN email TEXT;
ALTER TABLE callsign_cache ADD COLUMN qsl_via TEXT;
+30
View File
@@ -0,0 +1,30 @@
-- station_profiles: one row per operating configuration (home, portable,
-- SOTA, /MM, contest…). The user picks one as active; every QSO stamps
-- the active profile's MY_* fields. Place reserved for per-profile creds
-- (LoTW, Clublog, QRZ.com) in a later migration once those exports land.
CREATE TABLE station_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- "Home", "Portable", "SOTA"…
callsign TEXT NOT NULL DEFAULT '',
operator TEXT NOT NULL DEFAULT '',
my_grid TEXT NOT NULL DEFAULT '',
my_country TEXT NOT NULL DEFAULT '',
my_state TEXT NOT NULL DEFAULT '',
my_cnty TEXT NOT NULL DEFAULT '',
my_street TEXT NOT NULL DEFAULT '',
my_city TEXT NOT NULL DEFAULT '',
my_postal_code TEXT NOT NULL DEFAULT '',
my_sota_ref TEXT NOT NULL DEFAULT '',
my_pota_ref TEXT NOT NULL DEFAULT '',
my_rig TEXT NOT NULL DEFAULT '',
my_antenna TEXT NOT NULL DEFAULT '',
tx_pwr REAL, -- nullable: not always known
is_active INTEGER NOT NULL DEFAULT 0, -- 1 for the currently-selected profile
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
-- Only one profile can be active at a time. Enforced lazily — the Go side
-- clears all then sets one before each switch.
CREATE INDEX idx_station_profiles_active ON station_profiles(is_active);
+293
View File
@@ -0,0 +1,293 @@
// Package dxcc resolves a callsign to its DXCC entity (country, CQ/ITU
// zones, continent) by longest-prefix-matching against cty.dat — the
// canonical prefix database maintained by AD1C at country-files.com,
// the same file that every contest / logger consumes.
//
// The parser is line-oriented and tolerant: it handles cty.dat's
// per-prefix overrides ((CQ), [ITU], <lat/lon>, {Cont}) and the
// "=CALL" exact-callsign entries. Common operating suffixes (/P, /MM,
// /5, …) are stripped before matching.
package dxcc
import (
"bufio"
"io"
"sort"
"strconv"
"strings"
"sync"
)
// Entity is one DXCC entity entry from cty.dat.
type Entity struct {
Name string `json:"name"`
Continent string `json:"continent"`
CQZone int `json:"cqz"`
ITUZone int `json:"ituz"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
TZOffset float64 `json:"tz_offset"`
Primary string `json:"primary_prefix"` // canonical short prefix (F, DL, K, …)
}
// Match is the resolved DXCC info for a callsign. Per-prefix overrides
// from cty.dat are baked in; the Entity pointer is the unmodified parent.
type Match struct {
Entity *Entity `json:"entity"`
Prefix string `json:"matched_prefix"`
CQZone int `json:"cqz"`
ITUZone int `json:"ituz"`
Continent string `json:"continent"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
type prefixEntry struct {
prefix string
entity *Entity
cqOverride int
ituOverride int
contOverride string
latOverride float64
lonOverride float64
hasLatLon bool
}
// DB is a parsed cty.dat ready for lookups.
type DB struct {
mu sync.RWMutex
entities []*Entity
exact map[string]prefixEntry // "=CALLSIGN" entries
byPrefix []prefixEntry // sorted longest first
}
// Load parses a cty.dat stream. Safe to call once at startup.
func Load(r io.Reader) (*DB, error) {
db := &DB{exact: make(map[string]prefixEntry)}
sc := bufio.NewScanner(r)
// cty.dat lines can be ~2 KB after wrapping; default 64 KB buffer is fine
// but we bump it to be safe.
sc.Buffer(make([]byte, 64*1024), 1024*1024)
var current *Entity
var buf strings.Builder
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
if line == "" {
continue
}
// Entity header lines start at column 0; continuation lines are
// indented (cty.dat uses 4 spaces).
if line[0] != ' ' && line[0] != '\t' {
if e := parseEntityHeader(line); e != nil {
db.entities = append(db.entities, e)
current = e
buf.Reset()
}
continue
}
if current == nil {
continue
}
buf.WriteString(strings.TrimSpace(line))
// An entity's prefix list ends with ';' — possibly on a later line.
if strings.HasSuffix(strings.TrimSpace(line), ";") {
text := strings.TrimSuffix(buf.String(), ";")
for _, raw := range strings.Split(text, ",") {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
entry, exact := parsePrefix(raw, current)
if exact {
db.exact[entry.prefix] = entry
} else {
db.byPrefix = append(db.byPrefix, entry)
}
}
buf.Reset()
}
}
if err := sc.Err(); err != nil {
return nil, err
}
// Longest prefix first so HasPrefix wins on the most specific match.
sort.Slice(db.byPrefix, func(i, j int) bool {
return len(db.byPrefix[i].prefix) > len(db.byPrefix[j].prefix)
})
return db, nil
}
// Entities returns the parsed entity list (read-only).
func (db *DB) Entities() []*Entity {
db.mu.RLock()
defer db.mu.RUnlock()
return db.entities
}
// Lookup resolves a callsign to its DXCC match using longest-prefix-match.
// Strips operating suffixes (/P, /MM, /5…) and "operating-from" prefixes
// (DL/F4NIE → uses DL). Returns false if no prefix matches.
func (db *DB) Lookup(callsign string) (Match, bool) {
db.mu.RLock()
defer db.mu.RUnlock()
call := normalizeCallsign(callsign)
if call == "" {
return Match{}, false
}
if e, ok := db.exact[call]; ok {
return materialize(e), true
}
for _, p := range db.byPrefix {
if strings.HasPrefix(call, p.prefix) {
return materialize(p), true
}
}
return Match{}, false
}
func materialize(e prefixEntry) Match {
m := Match{
Entity: e.entity,
Prefix: e.prefix,
CQZone: e.entity.CQZone,
ITUZone: e.entity.ITUZone,
Continent: e.entity.Continent,
Lat: e.entity.Lat,
Lon: e.entity.Lon,
}
if e.cqOverride != 0 {
m.CQZone = e.cqOverride
}
if e.ituOverride != 0 {
m.ITUZone = e.ituOverride
}
if e.contOverride != "" {
m.Continent = e.contOverride
}
if e.hasLatLon {
m.Lat = e.latOverride
m.Lon = e.lonOverride
}
return m
}
// parseEntityHeader parses the colon-separated entity line:
//
// "France: 14: 27: EU: 46.00: -2.00: -1.0: F:"
func parseEntityHeader(line string) *Entity {
parts := strings.Split(line, ":")
if len(parts) < 8 {
return nil
}
e := &Entity{
Name: strings.TrimSpace(parts[0]),
Continent: strings.TrimSpace(parts[3]),
Primary: strings.TrimSpace(parts[7]),
}
e.CQZone, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
e.ITUZone, _ = strconv.Atoi(strings.TrimSpace(parts[2]))
e.Lat, _ = strconv.ParseFloat(strings.TrimSpace(parts[4]), 64)
e.Lon, _ = strconv.ParseFloat(strings.TrimSpace(parts[5]), 64)
e.TZOffset, _ = strconv.ParseFloat(strings.TrimSpace(parts[6]), 64)
if e.Name == "" {
return nil
}
return e
}
// parsePrefix peels off cty.dat per-prefix annotations:
//
// K (5)[7]<35.50/-95.00>{NA}~America/Chicago~
// =W1AW
func parsePrefix(s string, e *Entity) (prefixEntry, bool) {
out := prefixEntry{entity: e}
exact := false
if strings.HasPrefix(s, "=") {
exact = true
s = s[1:]
}
// Strip annotations. Order them roughly so we extract before they appear
// in the prefix slice.
s = stripAnnotation(s, '(', ')', func(v string) {
out.cqOverride, _ = strconv.Atoi(v)
})
s = stripAnnotation(s, '[', ']', func(v string) {
out.ituOverride, _ = strconv.Atoi(v)
})
s = stripAnnotation(s, '<', '>', func(v string) {
if a, b, ok := strings.Cut(v, "/"); ok {
lat, e1 := strconv.ParseFloat(a, 64)
lon, e2 := strconv.ParseFloat(b, 64)
if e1 == nil && e2 == nil {
out.latOverride, out.lonOverride = lat, lon
out.hasLatLon = true
}
}
})
s = stripAnnotation(s, '{', '}', func(v string) {
out.contOverride = strings.TrimSpace(v)
})
s = stripAnnotation(s, '~', '~', func(_ string) { /* timezone — ignore */ })
out.prefix = strings.ToUpper(strings.TrimSpace(s))
return out, exact
}
// stripAnnotation removes a single ...X...Y... block and invokes cb with the
// inner text. Used for (CQ), [ITU], <lat/lon>, {cont}, ~tz~ annotations.
func stripAnnotation(s string, open, close rune, cb func(string)) string {
i := strings.IndexRune(s, open)
if i < 0 {
return s
}
j := strings.IndexRune(s[i+1:], close)
if j < 0 {
return s
}
cb(s[i+1 : i+1+j])
return s[:i] + s[i+1+j+1:]
}
// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
// matching. /P /M /MM /AM /QRP /A and single-digit area changes (/5 …) all
// keep the operator's home DXCC.
var suffixModifiers = map[string]bool{
"P": true, "M": true, "MM": true, "AM": true, "QRP": true, "A": true,
"PM": true, "LH": true,
}
// normalizeCallsign uppercases, trims, and resolves the "active" call when
// the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE).
func normalizeCallsign(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if !strings.ContainsRune(s, '/') {
return s
}
parts := strings.Split(s, "/")
keep := parts[:0]
for _, p := range parts {
if p == "" {
continue
}
if suffixModifiers[p] {
continue
}
if len(p) == 1 && p >= "0" && p <= "9" {
continue
}
keep = append(keep, p)
}
switch len(keep) {
case 0:
return s
case 1:
return keep[0]
}
// Two non-modifier parts → operating-from prefix wins (shorter one).
// DL/F4NIE: DL is shorter → use DL (Germany). F4NIE/W6: W6 shorter → W6.
if len(keep[0]) <= len(keep[1]) {
return keep[0]
}
return keep[1]
}
+74
View File
@@ -0,0 +1,74 @@
package dxcc
import (
"strings"
"testing"
)
const sampleCty = `Sov Mil Order of Malta: 15: 28: EU: 41.90: -12.43: -1.0: 1A:
1A;
Monaco: 14: 27: EU: 43.73: -7.40: -1.0: 3A:
3A;
France: 14: 27: EU: 46.00: -2.00: -1.0: F:
F,HW,HX,HY,TH,TM,TO,TP,TQ,TV,TX;
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
DA,DB,DC,DD,DE,DF,DG,DH,DI,DJ,DK,DL,DM,DN,DO,DP,DQ,DR;
United States: 05: 08: NA: 37.53: 91.67: 5.0: K:
=W1AW(5)[7],K,N,W,AA,AB,AC,AD,AE,AF,AG,AH,AI,AJ,AK;
`
func TestLookup(t *testing.T) {
db, err := Load(strings.NewReader(sampleCty))
if err != nil {
t.Fatalf("load: %v", err)
}
cases := []struct {
call string
wantEnt string
}{
{"F4NIE", "France"},
{"F4BPO", "France"},
{"F4BPO/P", "France"},
{"DL/F4NIE", "Germany"},
{"DL5XYZ", "Germany"},
{"K1ABC", "United States"},
{"N0CALL", "United States"},
{"3A2MD", "Monaco"},
{"W1AW", "United States"}, // exact match wins
}
for _, c := range cases {
m, ok := db.Lookup(c.call)
if !ok {
t.Errorf("%s: no match", c.call)
continue
}
if m.Entity.Name != c.wantEnt {
t.Errorf("%s: got %q, want %q", c.call, m.Entity.Name, c.wantEnt)
}
}
// W1AW exact match has CQ override 5 and ITU override 7.
m, _ := db.Lookup("W1AW")
if m.CQZone != 5 || m.ITUZone != 7 {
t.Errorf("W1AW overrides: got CQ=%d ITU=%d, want 5/7", m.CQZone, m.ITUZone)
}
}
func TestNormalize(t *testing.T) {
cases := map[string]string{
"F4BPO": "F4BPO",
"f4bpo": "F4BPO",
" F4BPO ": "F4BPO",
"F4BPO/P": "F4BPO",
"F4BPO/MM": "F4BPO",
"F4BPO/5": "F4BPO",
"DL/F4BPO": "DL",
"F4BPO/W6": "W6",
"VK9/F4BPO": "VK9",
}
for in, want := range cases {
if got := normalizeCallsign(in); got != want {
t.Errorf("normalize(%q) = %q, want %q", in, got, want)
}
}
}
+156
View File
@@ -0,0 +1,156 @@
package dxcc
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
)
// CtyDatURL is the canonical source of cty.dat. AD1C ships updates roughly
// monthly; we cache the file on disk so we don't hammer it.
const CtyDatURL = "https://www.country-files.com/cty/cty.dat"
// Manager owns the on-disk cty.dat cache and the parsed DB. Safe for
// concurrent reads after Load; concurrent reloads serialize on its lock.
type Manager struct {
cacheDir string
mu sync.RWMutex
db *DB
src ctySource // metadata about whichever copy we loaded
loading atomic.Bool
}
type ctySource struct {
Path string `json:"path"`
LoadedAt time.Time `json:"loaded_at"`
FileModTime time.Time `json:"file_mod_time"`
Entities int `json:"entities"`
Downloaded bool `json:"downloaded"`
}
// NewManager prepares a manager rooted at cacheDir (created if missing).
// Does not load anything — call EnsureLoaded after.
func NewManager(cacheDir string) *Manager {
return &Manager{cacheDir: cacheDir}
}
// Path returns the on-disk path where cty.dat is/should be cached.
func (m *Manager) Path() string {
return filepath.Join(m.cacheDir, "cty.dat")
}
// EnsureLoaded loads cty.dat from disk; if missing, downloads it first.
// Safe to call repeatedly — only the first run actually downloads.
func (m *Manager) EnsureLoaded(ctx context.Context) error {
if _, err := os.Stat(m.Path()); os.IsNotExist(err) {
if err := m.Download(ctx); err != nil {
return fmt.Errorf("download cty.dat: %w", err)
}
}
return m.LoadFromDisk()
}
// LoadFromDisk parses the cached cty.dat into a fresh DB and swaps it in.
func (m *Manager) LoadFromDisk() error {
f, err := os.Open(m.Path())
if err != nil {
return err
}
defer f.Close()
info, _ := f.Stat()
db, err := Load(f)
if err != nil {
return err
}
m.mu.Lock()
m.db = db
m.src = ctySource{
Path: m.Path(),
LoadedAt: time.Now(),
FileModTime: info.ModTime(),
Entities: len(db.entities),
}
m.mu.Unlock()
return nil
}
// Download fetches a fresh cty.dat from CtyDatURL and atomically replaces
// the on-disk cache. Does NOT reload it into memory — caller can chain
// LoadFromDisk for that.
func (m *Manager) Download(ctx context.Context) error {
if !m.loading.CompareAndSwap(false, true) {
return fmt.Errorf("cty.dat download already in progress")
}
defer m.loading.Store(false)
if err := os.MkdirAll(m.cacheDir, 0o755); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "GET", CtyDatURL, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
// Write to a temp file in the same dir, then atomic rename — avoids a
// half-written file if we crash mid-download.
tmp, err := os.CreateTemp(m.cacheDir, "cty-*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
_, err = io.Copy(tmp, resp.Body)
tmp.Close()
if err != nil {
os.Remove(tmpPath)
return err
}
if err := os.Rename(tmpPath, m.Path()); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
// Refresh = Download + LoadFromDisk in one call.
func (m *Manager) Refresh(ctx context.Context) error {
if err := m.Download(ctx); err != nil {
return err
}
return m.LoadFromDisk()
}
// Lookup is a passthrough to the loaded DB. Returns false if no DB is
// loaded yet (callers should treat that as graceful degradation).
func (m *Manager) Lookup(callsign string) (Match, bool) {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
if db == nil {
return Match{}, false
}
return db.Lookup(callsign)
}
// Info returns metadata about the currently-loaded cty.dat (or zero value
// if nothing loaded).
func (m *Manager) Info() ctySource {
m.mu.RLock()
defer m.mu.RUnlock()
return m.src
}
+185
View File
@@ -0,0 +1,185 @@
package lookup
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// HamQTH is a lookup.Provider for hamqth.com (free with registration).
type HamQTH struct {
User string
Password string
HTTP *http.Client
mu sync.Mutex
session string
loggedAt time.Time
}
func NewHamQTH(user, password string) *HamQTH {
return &HamQTH{
User: user,
Password: password,
HTTP: &http.Client{Timeout: 12 * time.Second},
}
}
func (h *HamQTH) Name() string { return "hamqth" }
func (h *HamQTH) Lookup(ctx context.Context, callsign string) (Result, error) {
if h.User == "" || h.Password == "" {
return Result{}, fmt.Errorf("hamqth: credentials not set")
}
id, err := h.sessionID(ctx)
if err != nil {
return Result{}, err
}
r, err := h.fetch(ctx, id, callsign)
if err == errHamQTHSessionExpired {
h.mu.Lock()
h.session = ""
h.mu.Unlock()
id, err = h.sessionID(ctx)
if err != nil {
return Result{}, err
}
r, err = h.fetch(ctx, id, callsign)
}
return r, err
}
func (h *HamQTH) sessionID(ctx context.Context) (string, error) {
h.mu.Lock()
defer h.mu.Unlock()
// HamQTH sessions stay valid ~1h; re-login after 30min defensively.
if h.session != "" && time.Since(h.loggedAt) < 30*time.Minute {
return h.session, nil
}
u := fmt.Sprintf("https://www.hamqth.com/xml.php?u=%s&p=%s",
url.QueryEscape(h.User), url.QueryEscape(h.Password))
body, err := h.get(ctx, u)
if err != nil {
return "", err
}
var resp hamqthRoot
if err := xml.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("hamqth: parse session: %w", err)
}
if resp.Session.Error != "" {
return "", fmt.Errorf("hamqth login: %s", resp.Session.Error)
}
if resp.Session.ID == "" {
return "", fmt.Errorf("hamqth: empty session id")
}
h.session = resp.Session.ID
h.loggedAt = time.Now()
return h.session, nil
}
var errHamQTHSessionExpired = fmt.Errorf("hamqth: session expired")
func (h *HamQTH) fetch(ctx context.Context, sessionID, callsign string) (Result, error) {
u := fmt.Sprintf("https://www.hamqth.com/xml.php?id=%s&callsign=%s&prg=HamLog",
url.QueryEscape(sessionID), url.QueryEscape(callsign))
body, err := h.get(ctx, u)
if err != nil {
return Result{}, err
}
var resp hamqthRoot
if err := xml.Unmarshal(body, &resp); err != nil {
return Result{}, fmt.Errorf("hamqth: parse callsign: %w", err)
}
if resp.Session.Error != "" {
msg := strings.ToLower(resp.Session.Error)
if strings.Contains(msg, "session") || strings.Contains(msg, "expired") {
return Result{}, errHamQTHSessionExpired
}
if strings.Contains(msg, "not found") || strings.Contains(msg, "callsign") {
return Result{}, ErrNotFound
}
return Result{}, fmt.Errorf("hamqth: %s", resp.Session.Error)
}
s := resp.Search
if s.Callsign == "" {
return Result{}, ErrNotFound
}
r := Result{
Callsign: strings.ToUpper(s.Callsign),
Name: strings.TrimSpace(s.Nick + " " + s.LastName),
QTH: firstNonEmpty(s.QTH, s.AdrCity),
Address: s.AdrStreet1,
State: strings.ToUpper(s.USState),
County: s.USCounty,
Country: firstNonEmpty(s.AdrCountry, s.Country),
Grid: strings.ToUpper(s.Grid),
Continent: strings.ToUpper(s.Continent),
Email: s.Email,
QSLVia: s.QSLVia,
}
r.Lat, _ = strconv.ParseFloat(s.Latitude, 64)
r.Lon, _ = strconv.ParseFloat(s.Longitude, 64)
r.DXCC, _ = strconv.Atoi(s.DXCC)
r.CQZ, _ = strconv.Atoi(s.CQ)
r.ITUZ, _ = strconv.Atoi(s.ITU)
return r, nil
}
func (h *HamQTH) get(ctx context.Context, u string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return nil, err
}
resp, err := h.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("hamqth http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("hamqth http %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// ----- XML shapes -----
type hamqthRoot struct {
XMLName xml.Name `xml:"HamQTH"`
Session hamqthSession `xml:"session"`
Search hamqthSearch `xml:"search"`
}
type hamqthSession struct {
ID string `xml:"session_id"`
Error string `xml:"error"`
}
type hamqthSearch struct {
Callsign string `xml:"callsign"`
Nick string `xml:"nick"`
LastName string `xml:"name"`
QTH string `xml:"qth"`
AdrStreet1 string `xml:"adr_street1"`
AdrCity string `xml:"adr_city"`
Country string `xml:"country"`
AdrCountry string `xml:"adr_country"`
USState string `xml:"us_state"`
USCounty string `xml:"us_county"`
Grid string `xml:"grid"`
Latitude string `xml:"latitude"`
Longitude string `xml:"longitude"`
DXCC string `xml:"adif"` // HamQTH exposes the ADIF/DXCC number under <adif>
CQ string `xml:"cq"`
ITU string `xml:"itu"`
Continent string `xml:"continent"`
Email string `xml:"email"`
QSLVia string `xml:"qsl_via"`
}
+304
View File
@@ -0,0 +1,304 @@
// Package lookup queries callsign databases (QRZ.com, HamQTH) and caches
// results locally so we don't re-hit the network for known calls.
package lookup
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"sync"
"time"
)
// ErrNotFound is returned by providers when a callsign is unknown.
var ErrNotFound = errors.New("callsign not found")
// Result is the normalized lookup output regardless of provider.
type Result struct {
Callsign string `json:"callsign"`
Name string `json:"name,omitempty"`
QTH string `json:"qth,omitempty"`
Address string `json:"address,omitempty"`
State string `json:"state,omitempty"`
County string `json:"cnty,omitempty"`
Country string `json:"country,omitempty"`
Grid string `json:"grid,omitempty"`
Lat float64 `json:"lat,omitempty"`
Lon float64 `json:"lon,omitempty"`
DXCC int `json:"dxcc,omitempty"`
CQZ int `json:"cqz,omitempty"`
ITUZ int `json:"ituz,omitempty"`
Continent string `json:"cont,omitempty"`
Email string `json:"email,omitempty"`
QSLVia string `json:"qsl_via,omitempty"`
Source string `json:"source"` // "qrz", "hamqth", or "cache"
FetchedAt time.Time `json:"fetched_at"`
}
// Provider is the contract implemented by QRZ, HamQTH, etc.
type Provider interface {
Name() string
Lookup(ctx context.Context, callsign string) (Result, error)
}
// DXCCResolver fills the country / zones / continent when the providers
// don't (or when no provider returned anything). Decoupled via interface so
// `lookup` doesn't import the dxcc package directly.
type DXCCResolver interface {
Resolve(callsign string) (country, continent string, cqz, ituz int, lat, lon float64, ok bool)
}
// Manager composes a cache with one or more providers.
// Lookup tries the cache first, then each enabled provider in order.
type Manager struct {
mu sync.RWMutex
providers []Provider
cache *Cache
dxcc DXCCResolver
}
func NewManager(cache *Cache) *Manager {
return &Manager{cache: cache}
}
// SetDXCCResolver wires the cty.dat-backed fallback that fills country/
// zones when the provider chain comes up dry or short.
func (m *Manager) SetDXCCResolver(r DXCCResolver) {
m.mu.Lock()
defer m.mu.Unlock()
m.dxcc = r
}
// SetProviders replaces the provider chain. Safe to call at any time
// (e.g. after the user updates credentials in settings).
func (m *Manager) SetProviders(p ...Provider) {
m.mu.Lock()
defer m.mu.Unlock()
m.providers = p
}
// Lookup returns a Result for the callsign. Falls back through providers
// when one returns ErrNotFound or fails.
func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
return Result{}, fmt.Errorf("empty callsign")
}
if r, ok := m.cache.Get(ctx, call); ok {
r.Source = "cache"
return r, nil
}
m.mu.RLock()
providers := append([]Provider(nil), m.providers...)
dxcc := m.dxcc
m.mu.RUnlock()
var lastErr error
for _, p := range providers {
r, err := p.Lookup(ctx, call)
if err == nil {
r.Callsign = call
r.Source = p.Name()
r.FetchedAt = time.Now().UTC()
fillFromDXCC(&r, dxcc)
_ = m.cache.Put(ctx, r)
return r, nil
}
if errors.Is(err, ErrNotFound) {
lastErr = err
continue
}
lastErr = fmt.Errorf("%s: %w", p.Name(), err)
}
// All providers exhausted (not-found or errored). Try the cty.dat
// resolver as a last resort — at least we can hand back country/zones
// even for unknown callsigns. Not cached: a "cty.dat-only" result
// shouldn't suppress a later real lookup if the user adds creds.
if dxcc != nil {
var r Result
r.Callsign = call
if fillFromDXCC(&r, dxcc) {
r.Source = "cty.dat"
r.FetchedAt = time.Now().UTC()
return r, nil
}
}
if lastErr == nil {
if len(providers) == 0 {
lastErr = fmt.Errorf("no lookup provider configured")
} else {
lastErr = ErrNotFound
}
}
return Result{}, lastErr
}
// fillFromDXCC fills in country/continent/zones/lat/lon from the cty.dat
// resolver when the provider returned them empty. Provider data wins.
// Returns true if any field was filled.
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
if dxcc == nil {
return false
}
country, cont, cqz, ituz, lat, lon, ok := dxcc.Resolve(r.Callsign)
if !ok {
return false
}
filled := false
if r.Country == "" && country != "" {
r.Country = country
filled = true
}
if r.Continent == "" && cont != "" {
r.Continent = cont
filled = true
}
if r.CQZ == 0 && cqz != 0 {
r.CQZ = cqz
filled = true
}
if r.ITUZ == 0 && ituz != 0 {
r.ITUZ = ituz
filled = true
}
if r.Lat == 0 && lat != 0 {
r.Lat = lat
filled = true
}
if r.Lon == 0 && lon != 0 {
r.Lon = lon
filled = true
}
return filled
}
// ----- Cache -----
// Cache is a SQLite-backed cache of lookup results with a TTL.
type Cache struct {
db *sql.DB
ttl time.Duration
}
func NewCache(db *sql.DB, ttl time.Duration) *Cache {
if ttl <= 0 {
ttl = 30 * 24 * time.Hour
}
return &Cache{db: db, ttl: ttl}
}
// SetTTL updates the cache TTL (e.g. when user changes settings).
func (c *Cache) SetTTL(ttl time.Duration) {
if ttl > 0 {
c.ttl = ttl
}
}
// Get returns the cached result if present and not expired.
func (c *Cache) Get(ctx context.Context, callsign string) (Result, bool) {
row := c.db.QueryRowContext(ctx, `
SELECT callsign, name, qth, address, state, cnty, country, grid,
lat, lon, dxcc, cqz, ituz, cont, email, qsl_via,
source, fetched_at
FROM callsign_cache WHERE callsign = ?`, callsign)
var (
r Result
name, qth, addr, state, cnty sql.NullString
country, grid, cont, email, qslVia sql.NullString
src string
dxcc, cqz, ituz sql.NullInt64
lat, lon sql.NullFloat64
fetched string
)
if err := row.Scan(&r.Callsign, &name, &qth, &addr, &state, &cnty,
&country, &grid, &lat, &lon,
&dxcc, &cqz, &ituz, &cont, &email, &qslVia,
&src, &fetched); err != nil {
return Result{}, false
}
t, err := time.Parse("2006-01-02T15:04:05.000Z", fetched)
if err != nil {
t, _ = time.Parse(time.RFC3339, fetched)
}
if time.Since(t) > c.ttl {
return Result{}, false
}
r.Name = name.String
r.QTH = qth.String
r.Address = addr.String
r.State = state.String
r.County = cnty.String
r.Country = country.String
r.Grid = grid.String
r.Lat = lat.Float64
r.Lon = lon.Float64
r.Continent = cont.String
r.Email = email.String
r.QSLVia = qslVia.String
r.DXCC = int(dxcc.Int64)
r.CQZ = int(cqz.Int64)
r.ITUZ = int(ituz.Int64)
r.Source = src
r.FetchedAt = t
return r, true
}
// Put upserts a lookup result.
func (c *Cache) Put(ctx context.Context, r Result) error {
_, err := c.db.ExecContext(ctx, `
INSERT INTO callsign_cache(callsign, name, qth, address, state, cnty,
country, grid, lat, lon,
dxcc, cqz, ituz, cont, email, qsl_via,
source, fetched_at)
VALUES(?,?,?,?,?,?, ?,?,?,?, ?,?,?,?,?,?, ?,
strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(callsign) DO UPDATE SET
name = excluded.name, qth = excluded.qth, address = excluded.address,
state = excluded.state, cnty = excluded.cnty,
country = excluded.country, grid = excluded.grid,
lat = excluded.lat, lon = excluded.lon,
dxcc = excluded.dxcc, cqz = excluded.cqz, ituz = excluded.ituz,
cont = excluded.cont, email = excluded.email, qsl_via = excluded.qsl_via,
source = excluded.source, fetched_at = excluded.fetched_at`,
r.Callsign, nullable(r.Name), nullable(r.QTH), nullable(r.Address),
nullable(r.State), nullable(r.County),
nullable(r.Country), nullable(r.Grid),
nullableFloat(r.Lat), nullableFloat(r.Lon),
nullableInt(r.DXCC), nullableInt(r.CQZ), nullableInt(r.ITUZ),
nullable(r.Continent), nullable(r.Email), nullable(r.QSLVia),
r.Source,
)
return err
}
func nullableFloat(f float64) any {
if f == 0 {
return nil
}
return f
}
// Clear empties the cache. Useful for "Refresh cache" admin actions.
func (c *Cache) Clear(ctx context.Context) error {
_, err := c.db.ExecContext(ctx, `DELETE FROM callsign_cache`)
return err
}
func nullable(s string) any {
if s == "" {
return nil
}
return s
}
func nullableInt(n int) any {
if n == 0 {
return nil
}
return n
}
+235
View File
@@ -0,0 +1,235 @@
package lookup
import (
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// QRZ is a lookup.Provider for xmldata.qrz.com.
// A QRZ subscription is required for full data; basic info is free.
type QRZ struct {
User string
Password string
HTTP *http.Client
mu sync.Mutex
session string
loggedAt time.Time
}
func NewQRZ(user, password string) *QRZ {
return &QRZ{
User: user,
Password: password,
HTTP: &http.Client{Timeout: 12 * time.Second},
}
}
func (q *QRZ) Name() string { return "qrz" }
// Lookup queries QRZ for the given callsign.
func (q *QRZ) Lookup(ctx context.Context, callsign string) (Result, error) {
if q.User == "" || q.Password == "" {
return Result{}, fmt.Errorf("qrz: credentials not set")
}
key, err := q.sessionKey(ctx)
if err != nil {
return Result{}, err
}
r, err := q.fetch(ctx, key, callsign)
if err == errQRZSessionExpired {
// Force re-login and retry once.
q.mu.Lock()
q.session = ""
q.mu.Unlock()
key, err = q.sessionKey(ctx)
if err != nil {
return Result{}, err
}
r, err = q.fetch(ctx, key, callsign)
}
return r, err
}
func (q *QRZ) sessionKey(ctx context.Context) (string, error) {
q.mu.Lock()
defer q.mu.Unlock()
// QRZ sessions are valid ~24h; re-login defensively after 12h.
if q.session != "" && time.Since(q.loggedAt) < 12*time.Hour {
return q.session, nil
}
u := fmt.Sprintf("https://xmldata.qrz.com/xml/current/?username=%s;password=%s;agent=HamLog",
url.QueryEscape(q.User), url.QueryEscape(q.Password))
body, err := q.get(ctx, u)
if err != nil {
return "", err
}
var resp qrzDB
if err := xml.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("qrz: parse session: %w", err)
}
if resp.Session.Error != "" {
return "", fmt.Errorf("qrz login: %s", resp.Session.Error)
}
if resp.Session.Key == "" {
return "", fmt.Errorf("qrz: empty session key")
}
q.session = resp.Session.Key
q.loggedAt = time.Now()
return q.session, nil
}
var errQRZSessionExpired = fmt.Errorf("qrz: session expired")
func (q *QRZ) fetch(ctx context.Context, sessionKey, callsign string) (Result, error) {
u := fmt.Sprintf("https://xmldata.qrz.com/xml/current/?s=%s;callsign=%s",
url.QueryEscape(sessionKey), url.QueryEscape(callsign))
body, err := q.get(ctx, u)
if err != nil {
return Result{}, err
}
var resp qrzDB
if err := xml.Unmarshal(body, &resp); err != nil {
return Result{}, fmt.Errorf("qrz: parse callsign: %w", err)
}
if resp.Session.Error != "" {
msg := strings.ToLower(resp.Session.Error)
if strings.Contains(msg, "session") || strings.Contains(msg, "invalid") {
return Result{}, errQRZSessionExpired
}
if strings.Contains(msg, "not found") {
return Result{}, ErrNotFound
}
return Result{}, fmt.Errorf("qrz: %s", resp.Session.Error)
}
c := resp.Callsign
if c.Call == "" {
return Result{}, ErrNotFound
}
r := Result{
Callsign: strings.ToUpper(c.Call),
Name: joinName(c.FName, c.Name),
QTH: c.Addr2,
Address: composeQRZAddress(c.Addr1, c.Addr2, c.Zip, c.Country),
State: strings.ToUpper(c.State),
County: c.County,
Country: c.Country,
Grid: strings.ToUpper(c.Grid),
Continent: strings.ToUpper(c.Continent),
Email: c.Email,
QSLVia: c.QSLMgr,
}
r.Lat, _ = strconv.ParseFloat(c.Lat, 64)
r.Lon, _ = strconv.ParseFloat(c.Lon, 64)
r.DXCC, _ = strconv.Atoi(c.DXCC)
r.CQZ, _ = strconv.Atoi(c.CQZone)
r.ITUZ, _ = strconv.Atoi(c.ITUZone)
return r, nil
}
func (q *QRZ) get(ctx context.Context, u string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
if err != nil {
return nil, err
}
resp, err := q.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("qrz http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("qrz http %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// ----- XML shapes -----
type qrzDB struct {
XMLName xml.Name `xml:"QRZDatabase"`
Session qrzSession `xml:"Session"`
Callsign qrzCallsign `xml:"Callsign"`
}
type qrzSession struct {
Key string `xml:"Key"`
Error string `xml:"Error"`
}
type qrzCallsign struct {
Call string `xml:"call"`
FName string `xml:"fname"`
Name string `xml:"name"`
Addr1 string `xml:"addr1"`
Addr2 string `xml:"addr2"`
Zip string `xml:"zip"`
State string `xml:"state"`
County string `xml:"county"`
Country string `xml:"country"`
Grid string `xml:"grid"`
Lat string `xml:"lat"`
Lon string `xml:"lon"`
DXCC string `xml:"dxcc"`
CQZone string `xml:"cqzone"`
ITUZone string `xml:"ituzone"`
Continent string `xml:"cont"`
Email string `xml:"email"`
QSLMgr string `xml:"qslmgr"`
}
// composeQRZAddress builds a multi-line postal address from QRZ's separate
// fields (street, city, zip, country) the same way Log4OM displays it.
// addr1 is the street, addr2 is the city — skip addr1 when it duplicates
// the city (common for users who only filled the city).
func composeQRZAddress(addr1, addr2, zip, country string) string {
addr1 = strings.TrimSpace(addr1)
addr2 = strings.TrimSpace(addr2)
zip = strings.TrimSpace(zip)
country = strings.TrimSpace(country)
var parts []string
if addr1 != "" && !strings.EqualFold(addr1, addr2) {
parts = append(parts, addr1)
}
if addr2 != "" {
parts = append(parts, addr2)
}
if zip != "" {
parts = append(parts, zip)
}
if country != "" {
parts = append(parts, country)
}
return strings.Join(parts, ", ")
}
func joinName(first, last string) string {
first = strings.TrimSpace(first)
last = strings.TrimSpace(last)
switch {
case first != "" && last != "":
return first + " " + last
case first != "":
return first
default:
return last
}
}
func firstNonEmpty(s ...string) string {
for _, v := range s {
v = strings.TrimSpace(v)
if v != "" {
return v
}
}
return ""
}
+5
View File
@@ -0,0 +1,5 @@
// Package profile manages operator profiles: transmit callsign, locator,
// DXCC, operator, antenna/CAT config, etc. Lets the user switch quickly
// between several identities (home / portable / SOTA …).
// TODO: implementation.
package profile
+257
View File
@@ -0,0 +1,257 @@
// Package profile manages operator profiles: transmit callsign, locator,
// DXCC, operator, antenna/CAT config, etc. Lets the user switch quickly
// between several identities (home / portable / SOTA …).
//
// The active profile stamps every new QSO's MY_* fields (MY_GRIDSQUARE,
// MY_SOTA_REF, MY_RIG…). Future versions will also attach per-profile
// credentials (LoTW / Clublog / QRZ.com) so each callsign can export to
// its own account.
package profile
import (
"context"
"database/sql"
"fmt"
"time"
)
// Profile is one operating configuration. A user typically keeps a few:
// "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"…
type Profile struct {
ID int64 `json:"id"`
Name string `json:"name"`
Callsign string `json:"callsign"`
Operator string `json:"operator"`
MyGrid string `json:"my_grid"`
MyCountry string `json:"my_country"`
MyState string `json:"my_state"`
MyCounty string `json:"my_cnty"`
MyStreet string `json:"my_street"`
MyCity string `json:"my_city"`
MyPostalCode string `json:"my_postal_code"`
MySOTARef string `json:"my_sota_ref"`
MyPOTARef string `json:"my_pota_ref"`
MyRig string `json:"my_rig"`
MyAntenna string `json:"my_antenna"`
TxPower *float64 `json:"tx_pwr,omitempty"`
IsActive bool `json:"is_active"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Repo is a SQLite-backed profile store. All ops take a context so the
// HTTP/Wails frontend can cancel them on tab close.
type Repo struct{ db *sql.DB }
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
const selectCols = `id, name, callsign, operator, my_grid, my_country, my_state, my_cnty,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at`
// List returns every profile, active first then by sort_order/id.
func (r *Repo) List(ctx context.Context) ([]Profile, error) {
rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+`
FROM station_profiles
ORDER BY is_active DESC, sort_order ASC, id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Profile
for rows.Next() {
p, err := scan(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// Get returns one profile by ID, or sql.ErrNoRows if missing.
func (r *Repo) Get(ctx context.Context, id int64) (Profile, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+selectCols+`
FROM station_profiles WHERE id = ?`, id)
return scan(row)
}
// Active returns the currently-active profile, or sql.ErrNoRows if none.
func (r *Repo) Active(ctx context.Context) (Profile, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+selectCols+`
FROM station_profiles WHERE is_active = 1 LIMIT 1`)
return scan(row)
}
// Save upserts a profile. p.ID == 0 means "create". Updates touch
// updated_at; is_active is preserved separately via SetActive.
func (r *Repo) Save(ctx context.Context, p *Profile) error {
if p.Name == "" {
return fmt.Errorf("profile name required")
}
now := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
if p.ID == 0 {
res, err := r.db.ExecContext(ctx, `
INSERT INTO station_profiles
(name, callsign, operator, my_grid, my_country, my_state, my_cnty,
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at)
VALUES(?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?)`,
p.Name, p.Callsign, p.Operator, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef,
p.MyRig, p.MyAntenna, nullableFloat(p.TxPower), boolInt(p.IsActive), p.SortOrder, now, now)
if err != nil {
return err
}
id, _ := res.LastInsertId()
p.ID = id
return nil
}
_, err := r.db.ExecContext(ctx, `
UPDATE station_profiles SET
name = ?, callsign = ?, operator = ?, my_grid = ?, my_country = ?,
my_state = ?, my_cnty = ?, my_street = ?, my_city = ?, my_postal_code = ?,
my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?, tx_pwr = ?,
sort_order = ?, updated_at = ?
WHERE id = ?`,
p.Name, p.Callsign, p.Operator, p.MyGrid, p.MyCountry,
p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode,
p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna, nullableFloat(p.TxPower),
p.SortOrder, now, p.ID)
return err
}
// SetActive atomically switches the active profile. Clears the flag on all
// rows first to keep the "only one active" invariant from the schema doc.
func (r *Repo) SetActive(ctx context.Context, id int64) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `UPDATE station_profiles SET is_active = 0`); err != nil {
return err
}
res, err := tx.ExecContext(ctx, `UPDATE station_profiles SET is_active = 1 WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return tx.Commit()
}
// Delete removes a profile. Refuses to delete the last remaining profile
// (we always want at least one so QSO stamping doesn't crash). If the
// deleted one was active, the first remaining profile becomes active.
func (r *Repo) Delete(ctx context.Context, id int64) error {
var count int
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM station_profiles`).Scan(&count); err != nil {
return err
}
if count <= 1 {
return fmt.Errorf("cannot delete the last remaining profile")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var wasActive int
if err := tx.QueryRowContext(ctx, `SELECT is_active FROM station_profiles WHERE id = ?`, id).Scan(&wasActive); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM station_profiles WHERE id = ?`, id); err != nil {
return err
}
if wasActive == 1 {
// Promote the first remaining profile.
var newID int64
if err := tx.QueryRowContext(ctx,
`SELECT id FROM station_profiles ORDER BY sort_order ASC, id ASC LIMIT 1`).Scan(&newID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE station_profiles SET is_active = 1 WHERE id = ?`, newID); err != nil {
return err
}
}
return tx.Commit()
}
// Duplicate clones a profile under a new name (caller supplies it). The
// copy is created inactive — switching is an explicit user action.
func (r *Repo) Duplicate(ctx context.Context, srcID int64, newName string) (Profile, error) {
src, err := r.Get(ctx, srcID)
if err != nil {
return Profile{}, err
}
src.ID = 0
src.Name = newName
src.IsActive = false
src.SortOrder = 0
if err := r.Save(ctx, &src); err != nil {
return Profile{}, err
}
return src, nil
}
// ----- helpers -----
type scannable interface {
Scan(dest ...any) error
}
func scan(row scannable) (Profile, error) {
var p Profile
var (
callsign, operator, myGrid, myCountry, myState, myCnty,
myStreet, myCity, myPostal, mySOTA, myPOTA,
myRig, myAntenna sql.NullString
txPwr sql.NullFloat64
isActive, sortOrder int
createdAt, updatedAt string
)
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &myGrid, &myCountry, &myState, &myCnty,
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
&myRig, &myAntenna, &txPwr, &isActive, &sortOrder, &createdAt, &updatedAt)
if err != nil {
return p, err
}
p.Callsign = callsign.String
p.Operator = operator.String
p.MyGrid = myGrid.String
p.MyCountry = myCountry.String
p.MyState = myState.String
p.MyCounty = myCnty.String
p.MyStreet = myStreet.String
p.MyCity = myCity.String
p.MyPostalCode = myPostal.String
p.MySOTARef = mySOTA.String
p.MyPOTARef = myPOTA.String
p.MyRig = myRig.String
p.MyAntenna = myAntenna.String
if txPwr.Valid {
v := txPwr.Float64
p.TxPower = &v
}
p.IsActive = isActive == 1
p.SortOrder = sortOrder
p.CreatedAt, _ = time.Parse("2006-01-02T15:04:05.000Z", createdAt)
p.UpdatedAt, _ = time.Parse("2006-01-02T15:04:05.000Z", updatedAt)
return p, nil
}
func nullableFloat(p *float64) any {
if p == nil {
return nil
}
return *p
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
+82
View File
@@ -0,0 +1,82 @@
package profile
import (
"context"
"database/sql"
)
// SettingsReader is the minimal slice of the settings store we need for
// migrating legacy single-station settings into the first profile.
type SettingsReader interface {
GetMany(ctx context.Context, keys ...string) (map[string]string, error)
}
// EnsureDefault makes sure at least one profile exists and one is active.
//
// First-run path: if the table is empty, build a "Default" profile from
// the legacy `station.*` settings keys and mark it active — so the user's
// existing config carries over invisibly. Returns the active profile.
//
// The legacy key names are passed in from the caller (app.go) so this
// package doesn't need to import or duplicate them.
func EnsureDefault(ctx context.Context, db *sql.DB, settings SettingsReader, legacyKeys LegacyStationKeys) (Profile, error) {
repo := NewRepo(db)
var count int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM station_profiles`).Scan(&count); err != nil {
return Profile{}, err
}
if count == 0 {
seed, err := buildSeedFromSettings(ctx, settings, legacyKeys)
if err != nil {
return Profile{}, err
}
seed.IsActive = true
if err := repo.Save(ctx, &seed); err != nil {
return Profile{}, err
}
return seed, nil
}
// Profiles exist but none active (manual DB edit, deleted active row
// outside the app, …) — promote the first one.
if active, err := repo.Active(ctx); err == nil {
return active, nil
}
var firstID int64
if err := db.QueryRowContext(ctx,
`SELECT id FROM station_profiles ORDER BY sort_order ASC, id ASC LIMIT 1`).Scan(&firstID); err != nil {
return Profile{}, err
}
if err := repo.SetActive(ctx, firstID); err != nil {
return Profile{}, err
}
return repo.Get(ctx, firstID)
}
// LegacyStationKeys names the pre-profiles settings keys used to seed
// the first profile. Kept as a struct so adding/renaming a key doesn't
// silently fall through to an empty default.
type LegacyStationKeys struct {
Callsign string
Operator string
MyGrid string
Country string
SOTA string
POTA string
}
func buildSeedFromSettings(ctx context.Context, settings SettingsReader, keys LegacyStationKeys) (Profile, error) {
m, err := settings.GetMany(ctx,
keys.Callsign, keys.Operator, keys.MyGrid, keys.Country, keys.SOTA, keys.POTA)
if err != nil {
return Profile{}, err
}
return Profile{
Name: "Default",
Callsign: m[keys.Callsign],
Operator: m[keys.Operator],
MyGrid: m[keys.MyGrid],
MyCountry: m[keys.Country],
MySOTARef: m[keys.SOTA],
MyPOTARef: m[keys.POTA],
}, nil
}
+1040
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
package qso
import "testing"
// TestArgsMatchesColumnCount makes sure args() stays in lock-step with columnList.
// If you add or remove a column in columnList, you MUST add or remove the
// matching field in args() — this test catches drift the compiler can't.
func TestArgsMatchesColumnCount(t *testing.T) {
q := QSO{}
args := q.args()
if len(args) != columnCount {
t.Fatalf("args() returns %d values, columnList has %d columns", len(args), columnCount)
}
}
+3
View File
@@ -0,0 +1,3 @@
// Package rotator drives antenna rotators. Target backend: PstRotator (TCP).
// TODO: implementation.
package rotator
+69
View File
@@ -0,0 +1,69 @@
// Package settings is a tiny key/value store backed by the SQLite settings table.
package settings
import (
"context"
"database/sql"
"fmt"
)
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// Get returns the value for key, or "" if not set.
func (s *Store) Get(ctx context.Context, key string) (string, error) {
var v string
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if err == sql.ErrNoRows {
return "", nil
}
return v, err
}
// Set upserts a key/value pair.
func (s *Store) Set(ctx context.Context, key, value string) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO settings(key, value) VALUES(?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
key, value)
if err != nil {
return fmt.Errorf("set %s: %w", key, err)
}
return nil
}
// All returns every stored setting. Used by the UI to populate the prefs panel.
func (s *Store) All(ctx context.Context) (map[string]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
out[k] = v
}
return out, rows.Err()
}
// GetMany fetches several keys in a single round-trip.
func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string, error) {
out := make(map[string]string, len(keys))
for _, k := range keys {
v, err := s.Get(ctx, k)
if err != nil {
return nil, err
}
out[k] = v
}
return out, nil
}