7ace2cc602
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>
186 lines
4.8 KiB
Go
186 lines
4.8 KiB
Go
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"`
|
|
}
|