Files
rouggy 7ace2cc602 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>
2026-05-26 00:16:45 +02:00

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"`
}