feat: upload to external services clublog qrz

This commit is contained in:
2026-05-28 22:52:50 +02:00
parent e82e30dd02
commit 5c004f5e2f
26 changed files with 1710 additions and 31 deletions
+14
View File
@@ -77,6 +77,18 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
return count, err
}
// SingleRecordADIF returns one QSO serialised as an ADIF record (fields
// terminated by <EOR>), with no document header. Used by the external-
// service uploaders (QRZ.com / Clublog / …) which want a bare record as
// the ADIF parameter of their HTTP API.
func SingleRecordADIF(q qso.QSO) string {
var b strings.Builder
bw := bufio.NewWriter(&b)
writeRecord(bw, q)
bw.Flush()
return b.String()
}
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical
@@ -155,6 +167,8 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) {
writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
// --- Contest ---
writeField(bw, "CONTEST_ID", q.ContestID)
+3
View File
@@ -183,6 +183,7 @@ var adifPromoted = stringSet(
"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",
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
// Contest
"contest_id", "srx", "stx", "srx_string", "stx_string",
"check", "precedence", "arrl_sect",
@@ -312,6 +313,8 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.ClublogUploadStatus = rec["clublog_qso_upload_status"]
q.HRDLogUploadDate = rec["hrdlog_qso_upload_date"]
q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
q.QRZComUploadDate = rec["qrzcom_qso_upload_date"]
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
// Contest
q.ContestID = rec["contest_id"]
@@ -0,0 +1,6 @@
-- QRZ.com Logbook upload tracking. Like Clublog / HRDLog, QRZ.com is an
-- upload target: we stamp QRZCOM_QSO_UPLOAD_STATUS (and DATE) so OpsLog
-- can track which QSOs still need pushing and round-trip the standard
-- ADIF fields. Confirmations panel exposes the default status.
ALTER TABLE qso ADD COLUMN qrzcom_qso_upload_date TEXT;
ALTER TABLE qso ADD COLUMN qrzcom_qso_upload_status TEXT;
+25 -1
View File
@@ -146,7 +146,11 @@ var dxccByName = map[string]int{
"liechtenstein": 251,
"austria": 206,
"italy": 248,
"sicily": 225,
// Sicily (IT9) and African Italy (IG9/IH9) are cty.dat WAE splits, not
// DXCC entities — they count as Italy (248). Sardinia (IS0) IS its own
// DXCC entity (225) and keeps its number.
"sicily": 248,
"african italy": 248,
"sardinia": 225,
"spain": 281,
"portugal": 272,
@@ -318,3 +322,23 @@ func EntityDXCC(name string) int {
}
return dxccByName[strings.ToLower(strings.TrimSpace(name))]
}
// ctyEntityAliases maps cty.dat's non-DXCC pseudo-entities — the CQ-zone /
// WAE splits AD1C lists separately — onto the ARRL DXCC entity they belong
// to. cty.dat reports e.g. "Sicily" or "Sardinia" so contesters get the
// right zones, but for DXCC (and the COUNTRY field) they are Italy. Add an
// entry here for any other split that should report its parent entity.
var ctyEntityAliases = map[string]string{
"sicily": "Italy",
"african italy": "Italy",
// NB: Sardinia is intentionally absent — it's a real DXCC entity.
}
// CanonicalEntityName normalises a cty.dat entity name to its ARRL DXCC
// entity. Names that already are DXCC entities pass through unchanged.
func CanonicalEntityName(name string) string {
if c, ok := ctyEntityAliases[strings.ToLower(strings.TrimSpace(name))]; ok {
return c
}
return name
}
+14 -2
View File
@@ -181,10 +181,22 @@ func parseEntityHeader(line string) *Entity {
if len(parts) < 8 {
return nil
}
name := strings.TrimSpace(parts[0])
primary := strings.TrimSpace(parts[7])
// cty.dat marks non-DXCC entities (WAE / contest-only zone splits such
// as Sicily *IT9 and African Italy *IG9) with a leading '*' on the
// primary prefix. Those report under their parent DXCC entity. True
// DXCC entities — including Sardinia (IS0) and Corsica (TK) — have no
// '*' and keep their own name. Per-prefix zones/lat-lon are preserved,
// so e.g. IG9 still resolves to CQ 33 / continent AF under "Italy".
if strings.HasPrefix(primary, "*") {
primary = strings.TrimPrefix(primary, "*")
name = CanonicalEntityName(name)
}
e := &Entity{
Name: strings.TrimSpace(parts[0]),
Name: name,
Continent: strings.TrimSpace(parts[3]),
Primary: strings.TrimSpace(parts[7]),
Primary: primary,
}
e.CQZone, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
e.ITUZone, _ = strconv.Atoi(strings.TrimSpace(parts[2]))
+48
View File
@@ -54,6 +54,54 @@ func TestLookup(t *testing.T) {
}
}
// cty.dat marks non-DXCC entities (Sicily *IT9, African Italy *IG9) with a
// leading '*'; the parser must fold those into their parent DXCC entity
// "Italy" while leaving real DXCC entities — Sardinia (IS0), no '*' — alone.
func TestCanonicalEntityNames(t *testing.T) {
const cty = `Italy: 15: 28: EU: 42.82: -12.58: -1.0: I:
I,IK,IZ;
African Italy: 33: 37: AF: 35.67: -12.67: -1.0: *IG9:
IG9,IH9;
Sardinia: 15: 28: EU: 40.15: -9.27: -1.0: IS0:
IM0,IS,IW0U,IW0V;
Sicily: 15: 28: EU: 37.50: -14.00: -1.0: *IT9:
IT9,IW9;
`
db, err := Load(strings.NewReader(cty))
if err != nil {
t.Fatalf("load: %v", err)
}
cases := map[string]string{
"IW9EZO": "Italy", // Sicily (*IT9) → Italy
"IT9CLY": "Italy",
"IG9A": "Italy", // African Italy (*IG9) → Italy
"IK0ABC": "Italy",
"IS0XYZ": "Sardinia", // real DXCC entity — must stay Sardinia
"IM0ABC": "Sardinia",
}
for call, want := range cases {
m, ok := db.Lookup(call)
if !ok {
t.Errorf("%s: no match", call)
continue
}
if m.Entity.Name != want {
t.Errorf("%s: country = %q, want %q", call, m.Entity.Name, want)
}
}
// African Italy keeps its own zones/continent even though it reports
// Italy as the entity.
if m, _ := db.Lookup("IG9A"); m.CQZone != 33 || m.Continent != "AF" {
t.Errorf("IG9A: got CQ=%d cont=%s, want 33/AF", m.CQZone, m.Continent)
}
if EntityDXCC("Sicily") != 248 {
t.Errorf("EntityDXCC(Sicily) = %d, want 248 (Italy)", EntityDXCC("Sicily"))
}
if EntityDXCC("Sardinia") != 225 {
t.Errorf("EntityDXCC(Sardinia) = %d, want 225", EntityDXCC("Sardinia"))
}
}
func TestNormalize(t *testing.T) {
cases := map[string]string{
"F4BPO": "F4BPO",
+123
View File
@@ -0,0 +1,123 @@
package extsvc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint.
// (Batch ADIF goes to putlogs.php; we push one record per logged QSO.)
const clublogRealtimeURL = "https://clublog.org/realtime.php"
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
// requires an api parameter that identifies the client software (not the
// user) — the same way Log4OM embeds its own key — so we ship it baked in
// rather than asking each user for one. It's an application identifier, not
// a user secret, but note it is visible in the source and the binary.
const clublogAppAPIKey = "5767f19333363a9ef432ee9cd4141fe76b8adf38"
// UploadClublog pushes one ADIF record to Club Log in real time. The user
// supplies the account email + password and the logbook callsign; the
// application API key is embedded (clublogAppAPIKey), so users never need
// one — same UX as Log4OM.
//
// Form params:
//
// email, password, callsign, adif, clientident, api
//
// Club Log replies with HTTP 200 on success; on failure it returns a 4xx/5xx
// status with a plain-text reason in the body.
func UploadClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
switch {
case email == "":
return UploadResult{}, fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("clublog: password not set")
case call == "":
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
case strings.TrimSpace(adifRecord) == "":
return UploadResult{}, fmt.Errorf("clublog: empty adif record")
}
form := url.Values{}
form.Set("email", email)
form.Set("password", cfg.Password)
form.Set("callsign", call)
form.Set("adif", adifRecord)
form.Set("clientident", "OpsLog")
// Club Log requires the application API key. Use OpsLog's embedded key;
// a per-user override (cfg.APIKey) wins if one is ever configured.
api := strings.TrimSpace(cfg.APIKey)
if api == "" {
api = clublogAppAPIKey
}
form.Set("api", api)
res, err := clublogPost(ctx, client, clublogRealtimeURL, form)
if err != nil {
return UploadResult{}, err
}
return res, nil
}
// TestClublog validates the configured credentials by attempting a no-op
// style check. Club Log has no dedicated status endpoint, so we report the
// fields look complete; a real failure surfaces on the first upload.
func TestClublog(ctx context.Context, cfg ServiceConfig) (string, error) {
_ = ctx
switch {
case strings.TrimSpace(cfg.Email) == "":
return "", fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return "", fmt.Errorf("clublog: password not set")
case strings.TrimSpace(cfg.Callsign) == "":
return "", fmt.Errorf("clublog: logbook callsign not set")
}
return fmt.Sprintf("Ready — %s via %s", strings.ToUpper(strings.TrimSpace(cfg.Callsign)), strings.TrimSpace(cfg.Email)), nil
}
// clublogPost performs the form POST and maps the HTTP status to a result.
func clublogPost(ctx context.Context, client *http.Client, endpoint string, form url.Values) (UploadResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
msg := strings.TrimSpace(string(body))
switch {
case resp.StatusCode == http.StatusOK:
return UploadResult{OK: true, Message: msg}, nil
case isClublogDuplicate(resp.StatusCode, msg):
// Club Log rejects an exact duplicate; treat as already-logged.
return UploadResult{OK: true, Message: "already in logbook"}, nil
default:
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: upload failed: %s", msg)
}
}
// isClublogDuplicate recognises Club Log's "already have this QSO" rejection
// so repeated uploads stay idempotent.
func isClublogDuplicate(status int, msg string) bool {
m := strings.ToLower(msg)
return strings.Contains(m, "duplicate") || strings.Contains(m, "already")
}
+90
View File
@@ -0,0 +1,90 @@
// Package extsvc uploads logged QSOs to external logbook services
// (QRZ.com first; Clublog and LoTW to follow). Each service has its own
// credentials and an upload mode chosen per-service: "immediate" pushes as
// soon as the QSO is saved, "delayed" waits a random 12 minutes (like
// Log4OM) so a mistakenly-logged QSO can still be edited or removed before
// it leaves.
//
// The Manager is intentionally fire-and-forget: a failed or skipped upload
// just leaves the QSO's per-service upload-status column empty, and the
// (future) manual-upload window will let the user retry the backlog.
package extsvc
import (
"errors"
"strings"
)
// errFromResult turns a non-OK result with no transport error into one
// (defensive — uploaders normally return an error alongside !OK).
func errFromResult(r UploadResult) error {
if r.Message != "" {
return errors.New(r.Message)
}
return errors.New("upload rejected")
}
// Service identifies one external logbook.
type Service string
const (
ServiceQRZ Service = "qrz" // QRZ.com Logbook
ServiceClublog Service = "clublog" // Club Log real-time upload
// ServiceLoTW to come.
)
// UploadMode selects when an auto-upload fires after a QSO is saved.
type UploadMode string
const (
// ModeImmediate uploads as soon as the QSO is logged.
ModeImmediate UploadMode = "immediate"
// ModeDelayed waits a random 12 minutes before uploading.
ModeDelayed UploadMode = "delayed"
)
// ServiceConfig is the per-service user configuration. It's a superset of
// the credential shapes the different services need — each service reads
// only the fields it uses:
//
// QRZ.com → APIKey, ForceStationCallsign
// Club Log → Email, Password, Callsign, APIKey
//
// AutoUpload + UploadMode are common to all (timing is per-service, so the
// user can run e.g. Club Log immediate and QRZ delayed).
type ServiceConfig struct {
APIKey string `json:"api_key"`
Email string `json:"email"` // Club Log account email
Password string `json:"password"` // Club Log account password
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
ForceStationCallsign string `json:"force_station_callsign"` // QRZ
AutoUpload bool `json:"auto_upload"`
UploadMode UploadMode `json:"upload_mode"`
}
// normalised returns the config with whitespace trimmed and a valid upload
// mode (defaults to immediate).
func (c ServiceConfig) normalised() ServiceConfig {
c.APIKey = strings.TrimSpace(c.APIKey)
c.Email = strings.TrimSpace(c.Email)
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
if c.UploadMode != ModeDelayed {
c.UploadMode = ModeImmediate
}
return c
}
// ExternalServices bundles every service's config for the settings UI.
// LoTW fields will be added as that service lands.
type ExternalServices struct {
QRZ ServiceConfig `json:"qrz"`
Clublog ServiceConfig `json:"clublog"`
}
// UploadResult is the outcome of a single upload attempt.
type UploadResult struct {
OK bool // the service accepted (or already had) the QSO
LogID string // service-assigned record id, when provided
Message string // human-readable detail (reason on failure)
}
+160
View File
@@ -0,0 +1,160 @@
package extsvc
import (
"context"
"math/rand"
"net/http"
"sync"
"time"
)
// Deps are the host-app callbacks the Manager needs. Keeping them as
// function fields decouples extsvc from the qso/adif/settings packages and
// keeps the upload-scheduling logic testable.
type Deps struct {
Client *http.Client
// BuildADIF returns the ADIF record for a QSO id, with STATION_CALLSIGN
// overridden by forceCall when non-empty. ok=false means "skip silently"
// (row gone, missing required fields, …).
BuildADIF func(id int64, forceCall string) (record string, ok bool)
// MarkUploaded stamps the per-service upload status on the QSO row and
// notifies the UI. Called once, on success.
MarkUploaded func(svc Service, id int64, logID string)
// NotifyError surfaces a failed upload (logging + optional UI event).
NotifyError func(svc Service, id int64, err error)
// Logf is an optional diagnostic logger.
Logf func(format string, args ...any)
}
// Manager owns the external-service config snapshot and schedules uploads
// when a QSO is logged. Immediate uploads run in their own goroutine;
// delayed uploads use a timer with a random 12 minute fuse.
type Manager struct {
deps Deps
mu sync.Mutex
cfg ExternalServices
rnd *rand.Rand
}
func NewManager(deps Deps) *Manager {
if deps.Client == nil {
deps.Client = &http.Client{Timeout: 20 * time.Second}
}
return &Manager{
deps: deps,
// Seeded from the clock; the delay only needs to be unpredictable
// enough to spread bursts, not cryptographically random.
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
func (m *Manager) logf(format string, args ...any) {
if m.deps.Logf != nil {
m.deps.Logf(format, args...)
}
}
// SetConfig replaces the active config snapshot (called after the user
// saves the External Services settings).
func (m *Manager) SetConfig(cfg ExternalServices) {
m.mu.Lock()
defer m.mu.Unlock()
cfg.QRZ = cfg.QRZ.normalised()
m.cfg = cfg
}
// Config returns the current snapshot.
func (m *Manager) Config() ExternalServices {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg
}
// delaySeconds returns a random 60120s fuse for delayed uploads.
func (m *Manager) delaySeconds() time.Duration {
m.mu.Lock()
d := 60 + m.rnd.Intn(61) // [60, 120]
m.mu.Unlock()
return time.Duration(d) * time.Second
}
// OnQSOLogged is called after a QSO is inserted (manual entry or UDP
// auto-log). It fans out to every enabled, auto-upload service in the
// configured timing mode. Returns immediately.
func (m *Manager) OnQSOLogged(id int64) {
cfg := m.Config()
// QRZ.com
if qrz := cfg.QRZ; qrz.AutoUpload && qrz.APIKey != "" {
m.scheduleUpload(ServiceQRZ, id, qrz)
}
// Club Log — email + password + callsign are enough (no API key).
if cl := cfg.Clublog; cl.AutoUpload && cl.Email != "" && cl.Password != "" {
m.scheduleUpload(ServiceClublog, id, cl)
}
// LoTW will be added here.
}
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
if cfg.UploadMode == ModeDelayed {
d := m.delaySeconds()
m.logf("extsvc: %s upload of QSO %d scheduled in %s", svc, id, d)
time.AfterFunc(d, func() { m.upload(svc, id, cfg) })
return
}
go m.upload(svc, id, cfg)
}
// upload performs the actual push. It builds a fresh, lifecycle-independent
// context so a delayed upload still completes even if it fires close to
// shutdown.
func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var res UploadResult
var err error
switch svc {
case ServiceQRZ:
// QRZ rewrites STATION_CALLSIGN to the registered call.
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return
}
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
case ServiceClublog:
// Club Log takes the logbook callsign as a separate param, so the
// ADIF keeps the QSO's own station call (no override).
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return
}
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
default:
return
}
if err != nil || !res.OK {
if err == nil {
err = errFromResult(res)
}
m.logf("extsvc: %s upload of QSO %d failed: %v", svc, id, err)
if m.deps.NotifyError != nil {
m.deps.NotifyError(svc, id, err)
}
return
}
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
if m.deps.MarkUploaded != nil {
m.deps.MarkUploaded(svc, id, res.LogID)
}
}
+157
View File
@@ -0,0 +1,157 @@
package extsvc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// qrzAPIURL is the QRZ.com Logbook API endpoint. Note this is the LOGBOOK
// API (key from the logbook's settings page), NOT the XML lookup
// subscription used elsewhere for callsign data — they're different keys.
const qrzAPIURL = "https://logbook.qrz.com/api"
// UploadQRZ pushes one ADIF record to the QRZ.com logbook identified by
// apiKey. It returns OK when the QSO is inserted or already present
// (QRZ reports a duplicate as a FAIL with a "duplicate" reason, which we
// treat as success so retries are idempotent).
//
// API shape (form-encoded POST):
//
// KEY=<logbook key>&ACTION=INSERT&ADIF=<one record>&OPTION=
//
// Response is URL-encoded key/values, e.g.:
//
// STATUS=OK&LOGID=123456&COUNT=1&...
// STATUS=FAIL&REASON=Unable+to+add+QSO+...&...
// STATUS=AUTH&REASON=invalid+api+key&...
func UploadQRZ(ctx context.Context, client *http.Client, apiKey, adifRecord string) (UploadResult, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return UploadResult{}, fmt.Errorf("qrz: api key not set")
}
if strings.TrimSpace(adifRecord) == "" {
return UploadResult{}, fmt.Errorf("qrz: empty adif record")
}
form := url.Values{}
form.Set("KEY", apiKey)
form.Set("ACTION", "INSERT")
// OPTION=REPLACE would overwrite an existing matching QSO; we leave it
// empty so QRZ rejects duplicates (which we map to OK below).
form.Set("ADIF", adifRecord)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode()))
if err != nil {
return UploadResult{}, fmt.Errorf("qrz: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, fmt.Errorf("qrz: request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return UploadResult{}, fmt.Errorf("qrz: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return UploadResult{}, fmt.Errorf("qrz: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return parseQRZResponse(string(body))
}
// TestQRZ checks a logbook API key with ACTION=STATUS and returns a short
// human-readable summary (callsign + QSO count) for the settings UI. An
// invalid key comes back as STATUS=AUTH → returned as an error.
func TestQRZ(ctx context.Context, client *http.Client, apiKey string) (string, error) {
apiKey = strings.TrimSpace(apiKey)
if apiKey == "" {
return "", fmt.Errorf("qrz: api key not set")
}
form := url.Values{}
form.Set("KEY", apiKey)
form.Set("ACTION", "STATUS")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, qrzAPIURL, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("qrz: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("qrz: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
vals, err := url.ParseQuery(strings.TrimSpace(string(body)))
if err != nil {
return "", fmt.Errorf("qrz: bad response: %w", err)
}
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
if status == "AUTH" || status == "FAIL" {
reason := strings.TrimSpace(vals.Get("REASON"))
if reason == "" {
reason = "invalid API key"
}
return "", fmt.Errorf("qrz: %s", reason)
}
call := strings.TrimSpace(vals.Get("CALLSIGN"))
count := strings.TrimSpace(vals.Get("COUNT"))
switch {
case call != "" && count != "":
return fmt.Sprintf("Connected — %s logbook, %s QSOs", call, count), nil
case call != "":
return fmt.Sprintf("Connected — %s logbook", call), nil
default:
return "Connected — key OK", nil
}
}
// parseQRZResponse decodes QRZ's "&"-joined, URL-encoded reply.
func parseQRZResponse(body string) (UploadResult, error) {
vals, err := url.ParseQuery(strings.TrimSpace(body))
if err != nil {
return UploadResult{}, fmt.Errorf("qrz: bad response %q: %w", body, err)
}
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
reason := strings.TrimSpace(vals.Get("REASON"))
logID := strings.TrimSpace(vals.Get("LOGID"))
switch status {
case "OK":
return UploadResult{OK: true, LogID: logID, Message: reason}, nil
case "FAIL":
// A duplicate is a benign failure — the QSO is in the logbook, so
// from our side the upload "succeeded". Detect it from the reason.
if isDuplicateReason(reason) {
return UploadResult{OK: true, LogID: logID, Message: "already in logbook"}, nil
}
return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: upload failed: %s", reason)
case "AUTH":
return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: auth error: %s", reason)
default:
return UploadResult{OK: false, Message: reason}, fmt.Errorf("qrz: unexpected status %q (%s)", status, reason)
}
}
// isDuplicateReason recognises the various phrasings QRZ uses when a QSO is
// already present.
func isDuplicateReason(reason string) bool {
r := strings.ToLower(reason)
return strings.Contains(r, "duplicate") ||
strings.Contains(r, "already") ||
strings.Contains(r, "unable to add") && strings.Contains(r, "exists")
}
+33
View File
@@ -0,0 +1,33 @@
package extsvc
import "testing"
func TestParseQRZResponse(t *testing.T) {
cases := []struct {
name string
body string
wantOK bool
wantErr bool
logID string
}{
{"insert ok", "STATUS=OK&LOGID=123456&COUNT=1", true, false, "123456"},
{"duplicate is ok", "STATUS=FAIL&REASON=Unable+to+add+QSO+duplicate", true, false, ""},
{"already present", "STATUS=FAIL&REASON=QSO+already+in+logbook", true, false, ""},
{"real failure", "STATUS=FAIL&REASON=Bad+ADIF", false, true, ""},
{"auth failure", "STATUS=AUTH&REASON=invalid+api+key", false, true, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
res, err := parseQRZResponse(c.body)
if (err != nil) != c.wantErr {
t.Fatalf("err = %v, wantErr %v", err, c.wantErr)
}
if res.OK != c.wantOK {
t.Errorf("OK = %v, want %v", res.OK, c.wantOK)
}
if c.logID != "" && res.LogID != c.logID {
t.Errorf("LogID = %q, want %q", res.LogID, c.logID)
}
})
}
}
+219
View File
@@ -0,0 +1,219 @@
package udp
import (
"bytes"
"encoding/xml"
"fmt"
"strconv"
"strings"
"time"
)
// N1MM Logger+ broadcasts each logged contact as a UTF-8 XML datagram.
// We care about the two that represent a completed QSO:
//
// <contactinfo> a freshly logged contact
// <contactreplace> an edited contact (same shape; we treat it as a log)
//
// Everything else N1MM emits on the same socket — <spot>, <RadioInfo>,
// <dynamicresults>, <AppInfo>, <contactdelete> — is ignored here (spots
// are a separate feature; deletes/status aren't auto-logged).
//
// N1MM frequencies are in tens of Hz (rxfreq 1402500 == 14.025 MHz), and
// the <band> tag is the band edge in MHz as a bare number ("14", "3.5").
// We derive the ADIF band from the frequency when we have it and fall back
// to the band tag otherwise.
//
// Rather than build a qso.QSO by hand we synthesise an ADIF record and feed
// it back through the same auto-log path WSJT-X uses (LogUDPLoggedADIF):
// that gets us lookup enrichment, DXCC stamping, the operating-conditions
// stamp and dedup for free.
// n1mmContact maps the subset of <contactinfo>/<contactreplace> fields we
// promote into the logbook. Unmapped tags are dropped; anything we keep but
// the ADIF importer doesn't promote lands in the QSO's Extras.
type n1mmContact struct {
Call string `xml:"call"`
Mode string `xml:"mode"`
Band string `xml:"band"` // band edge in MHz, e.g. "14"
RxFreq string `xml:"rxfreq"` // tens of Hz; string so empty decodes cleanly
TxFreq string `xml:"txfreq"`
Timestamp string `xml:"timestamp"` // "2006-01-02 15:04:05", UTC
MyCall string `xml:"mycall"`
Operator string `xml:"operator"`
Snt string `xml:"snt"`
SntNr string `xml:"sntnr"`
Rcv string `xml:"rcv"`
RcvNr string `xml:"rcvnr"`
Grid string `xml:"gridsquare"`
Name string `xml:"name"`
QTH string `xml:"qth"`
Comment string `xml:"comment"`
Power string `xml:"power"`
ContestName string `xml:"contestname"`
}
// ParseN1MM decodes one N1MM UDP datagram. It returns ok=false (with no
// error) for datagrams that aren't a loggable contact. For a contact it
// returns a synthesised ADIF record ready for the auto-log path.
func ParseN1MM(pkt []byte) (adifText string, ok bool, err error) {
dec := xml.NewDecoder(bytes.NewReader(pkt))
for {
tok, terr := dec.Token()
if terr != nil {
// EOF before any start element, or malformed XML.
return "", false, fmt.Errorf("n1mm: no element: %w", terr)
}
se, isStart := tok.(xml.StartElement)
if !isStart {
continue
}
switch se.Name.Local {
case "contactinfo", "contactreplace":
var c n1mmContact
if derr := dec.DecodeElement(&c, &se); derr != nil {
return "", false, fmt.Errorf("n1mm: decode %s: %w", se.Name.Local, derr)
}
return c.toADIF()
default:
// spot / RadioInfo / dynamicresults / contactdelete / etc.
return "", false, nil
}
}
}
// toADIF turns a parsed contact into an ADIF record string. Returns
// ok=false if the required call/mode/date fields are missing — better to
// skip silently than to hand the auto-log path an unloggable record.
func (c n1mmContact) toADIF() (string, bool, error) {
call := strings.ToUpper(strings.TrimSpace(c.Call))
mode := normaliseN1MMMode(c.Mode)
t, terr := parseN1MMTimestamp(c.Timestamp)
if call == "" || mode == "" || terr != nil {
return "", false, nil
}
var freqHz int64
if raw := strings.TrimSpace(c.RxFreq); raw != "" {
if tens, perr := strconv.ParseInt(raw, 10, 64); perr == nil {
freqHz = tens * 10
}
}
band := bandFromHz(freqHz)
if band == "" {
band = bandFromMHzTag(c.Band)
}
if band == "" {
// No band, no log — the importer requires it.
return "", false, nil
}
var b strings.Builder
writeADIFField(&b, "call", call)
writeADIFField(&b, "qso_date", t.Format("20060102"))
writeADIFField(&b, "time_on", t.Format("150405"))
writeADIFField(&b, "band", band)
writeADIFField(&b, "mode", mode)
if freqHz > 0 {
// MHz with kHz precision, ADIF style: "14.025000".
writeADIFField(&b, "freq", strconv.FormatFloat(float64(freqHz)/1e6, 'f', 6, 64))
}
writeADIFField(&b, "rst_sent", strings.TrimSpace(c.Snt))
writeADIFField(&b, "rst_rcvd", strings.TrimSpace(c.Rcv))
writeADIFField(&b, "gridsquare", strings.TrimSpace(c.Grid))
writeADIFField(&b, "name", strings.TrimSpace(c.Name))
writeADIFField(&b, "qth", strings.TrimSpace(c.QTH))
writeADIFField(&b, "comment", strings.TrimSpace(c.Comment))
writeADIFField(&b, "tx_pwr", strings.TrimSpace(c.Power))
writeADIFField(&b, "operator", strings.ToUpper(strings.TrimSpace(c.MyCall)))
writeADIFField(&b, "contest_id", strings.TrimSpace(c.ContestName))
writeADIFField(&b, "stx", strings.TrimSpace(c.SntNr))
writeADIFField(&b, "srx", strings.TrimSpace(c.RcvNr))
b.WriteString("<eor>\n")
return b.String(), true, nil
}
// writeADIFField appends a single "<name:len>value" field, skipping empties.
func writeADIFField(b *strings.Builder, name, value string) {
if value == "" {
return
}
fmt.Fprintf(b, "<%s:%d>%s", name, len(value), value)
}
// normaliseN1MMMode maps N1MM mode strings onto ADIF modes. N1MM reports
// the sideband (USB/LSB) where ADIF wants the parent mode SSB; everything
// else passes through upper-cased.
func normaliseN1MMMode(mode string) string {
m := strings.ToUpper(strings.TrimSpace(mode))
switch m {
case "USB", "LSB":
return "SSB"
default:
return m
}
}
// parseN1MMTimestamp parses N1MM's "2006-01-02 15:04:05" UTC timestamp.
func parseN1MMTimestamp(ts string) (time.Time, error) {
return time.Parse("2006-01-02 15:04:05", strings.TrimSpace(ts))
}
// n1mmBand is one entry in the band-plan table used to derive an ADIF band
// from a dial frequency.
type n1mmBand struct {
loHz, hiHz int64
name string
}
// bandPlan covers the HF/VHF/UHF allocations a logger is likely to see.
// Ranges are generous (band edges, not country sub-bands) so an out-of-band
// dial reading still maps to the nearest band.
var bandPlan = []n1mmBand{
{1_800_000, 2_000_000, "160m"},
{3_500_000, 4_000_000, "80m"},
{5_060_000, 5_450_000, "60m"},
{7_000_000, 7_300_000, "40m"},
{10_100_000, 10_150_000, "30m"},
{14_000_000, 14_350_000, "20m"},
{18_068_000, 18_168_000, "17m"},
{21_000_000, 21_450_000, "15m"},
{24_890_000, 24_990_000, "12m"},
{28_000_000, 29_700_000, "10m"},
{50_000_000, 54_000_000, "6m"},
{70_000_000, 71_000_000, "4m"},
{144_000_000, 148_000_000, "2m"},
{222_000_000, 225_000_000, "1.25m"},
{420_000_000, 450_000_000, "70cm"},
{902_000_000, 928_000_000, "33cm"},
{1_240_000_000, 1_300_000_000, "23cm"},
}
// bandFromHz returns the ADIF band token for a dial frequency, or "" when
// the frequency is zero or outside every known allocation.
func bandFromHz(hz int64) string {
if hz <= 0 {
return ""
}
for _, b := range bandPlan {
if hz >= b.loHz && hz <= b.hiHz {
return b.name
}
}
return ""
}
// bandFromMHzTag maps N1MM's bare-MHz <band> tag ("14", "3.5") onto an ADIF
// band by treating it as a frequency at the band's low edge.
func bandFromMHzTag(tag string) string {
tag = strings.TrimSpace(tag)
if tag == "" {
return ""
}
mhz, err := strconv.ParseFloat(tag, 64)
if err != nil {
return ""
}
// Nudge just inside the low edge so e.g. "14" lands in 20m.
return bandFromHz(int64(mhz*1_000_000) + 1)
}
+103
View File
@@ -0,0 +1,103 @@
package udp
import (
"strings"
"testing"
)
// A representative N1MM+ <contactinfo> datagram (trimmed to the fields we
// read). rxfreq 1402500 tens-of-Hz == 14.025 MHz → 20m.
const sampleContactInfo = `<?xml version="1.0" encoding="utf-8"?>
<contactinfo>
<contestname>DX</contestname>
<timestamp>2024-03-15 14:25:30</timestamp>
<mycall>K1ABC</mycall>
<band>14</band>
<rxfreq>1402500</rxfreq>
<txfreq>1402500</txfreq>
<operator>K1ABC</operator>
<mode>CW</mode>
<call>VE9AA</call>
<snt>599</snt>
<sntnr>1</sntnr>
<rcv>599</rcv>
<rcvnr>42</rcvnr>
<gridsquare>FN65</gridsquare>
<name>Mike</name>
<comment>tnx</comment>
<power>100</power>
</contactinfo>`
func TestParseN1MMContactInfo(t *testing.T) {
adif, ok, err := ParseN1MM([]byte(sampleContactInfo))
if err != nil {
t.Fatalf("ParseN1MM error: %v", err)
}
if !ok {
t.Fatal("expected a loggable contact, got ok=false")
}
want := map[string]string{
"<call:5>VE9AA": "callsign",
"<qso_date:8>20240315": "date",
"<time_on:6>142530": "time",
"<band:3>20m": "band",
"<mode:2>CW": "mode",
"<freq:9>14.025000": "freq",
"<rst_sent:3>599": "rst sent",
"<rst_rcvd:3>599": "rst rcvd",
"<gridsquare:4>FN65": "grid",
"<name:4>Mike": "name",
"<stx:1>1": "stx serial",
"<srx:2>42": "srx serial",
"<eor>": "terminator",
}
for sub, label := range want {
if !strings.Contains(adif, sub) {
t.Errorf("missing %s field %q in:\n%s", label, sub, adif)
}
}
}
func TestParseN1MMSSBMapping(t *testing.T) {
pkt := `<contactinfo><call>F4XYZ</call><mode>USB</mode><band>14</band><rxfreq>1420000</rxfreq><timestamp>2024-01-01 00:00:00</timestamp></contactinfo>`
adif, ok, err := ParseN1MM([]byte(pkt))
if err != nil || !ok {
t.Fatalf("ParseN1MM ok=%v err=%v", ok, err)
}
if !strings.Contains(adif, "<mode:3>SSB") {
t.Errorf("USB should map to SSB, got:\n%s", adif)
}
}
func TestParseN1MMIgnoresNonContacts(t *testing.T) {
for _, pkt := range []string{
`<RadioInfo><app>N1MM</app><freq>1402500</freq></RadioInfo>`,
`<spot><dxcall>VE9AA</dxcall><frequency>14025</frequency></spot>`,
`<contactdelete><call>VE9AA</call></contactdelete>`,
} {
_, ok, err := ParseN1MM([]byte(pkt))
if err != nil {
t.Errorf("unexpected error for %q: %v", pkt, err)
}
if ok {
t.Errorf("expected ok=false (ignored) for %q", pkt)
}
}
}
func TestBandFromHz(t *testing.T) {
cases := map[int64]string{
14_025_000: "20m",
7_100_000: "40m",
3_650_000: "80m",
28_400_000: "10m",
144_200_000: "2m",
0: "",
15_000_000: "", // between 20m and 17m → no band
}
for hz, want := range cases {
if got := bandFromHz(hz); got != want {
t.Errorf("bandFromHz(%d) = %q, want %q", hz, got, want)
}
}
}
+24 -5
View File
@@ -45,8 +45,7 @@ type Event struct {
DXGrid string // ServiceWSJT (Status)
Mode string // ServiceWSJT (Status)
FreqHz int64 // ServiceWSJT (Status)
LoggedADIF string // ServiceWSJT (LoggedADIF) or ServiceADIF
RawText string // generic fallback (n1mm xml, etc.)
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
}
// Server is a single inbound UDP listener.
@@ -187,7 +186,17 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.FreqHz = w.FreqHz
ev.LoggedADIF = w.LoggedADIF
case ServiceADIF:
ev.LoggedADIF = string(pkt)
// JTAlert / GridTracker forward a text ADIF record after a QSO is
// logged. Guard against keep-alive / non-ADIF chatter on the socket:
// only forward payloads that actually carry a callsign field and a
// record terminator.
text := string(pkt)
low := strings.ToLower(text)
if !strings.Contains(low, "<call:") || !strings.Contains(low, "<eor") {
applog.Printf("udp: [%s] ADIF payload ignored (no <call:>/<eor>)\n", s.cfg.Name)
return
}
ev.LoggedADIF = text
case ServiceRemoteCall:
// Common payload shapes seen in the wild:
// "F4XYZ" (bare callsign)
@@ -217,12 +226,22 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
}
ev.DXCall = strings.ToUpper(parts[len(parts)-1])
case ServiceN1MM:
ev.RawText = string(pkt)
adifText, ok, err := ParseN1MM(pkt)
if err != nil {
applog.Printf("udp: [%s] N1MM parse error: %v\n", s.cfg.Name, err)
return
}
if !ok {
applog.Printf("udp: [%s] N1MM datagram ignored (not a loggable contact)\n", s.cfg.Name)
return
}
applog.Printf("udp: [%s] N1MM contact decoded (%d bytes ADIF)\n", s.cfg.Name, len(adifText))
ev.LoggedADIF = adifText
default:
return
}
// Empty events are useless; skip.
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.RawText == "" {
if ev.DXCall == "" && ev.LoggedADIF == "" {
return
}
select {
+10 -5
View File
@@ -88,16 +88,21 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
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()
if r, ok := m.cache.Get(ctx, call); ok {
r.Source = "cache"
// Re-assert the authoritative DXCC fields (country/zones/continent)
// from cty.dat on every cache hit — cheap (in-memory) and lets a
// corrected entity mapping (e.g. Sicily → Italy) heal stale cached
// rows without waiting for the TTL to expire.
fillFromDXCC(&r, dxcc)
return r, nil
}
var lastErr error
for _, p := range providers {
r, err := p.Lookup(ctx, call)
+36
View File
@@ -76,6 +76,8 @@ type QSO struct {
ClublogUploadStatus string `json:"clublog_qso_upload_status,omitempty"`
HRDLogUploadDate string `json:"hrdlog_qso_upload_date,omitempty"`
HRDLogUploadStatus string `json:"hrdlog_qso_upload_status,omitempty"`
QRZComUploadDate string `json:"qrzcom_qso_upload_date,omitempty"`
QRZComUploadStatus string `json:"qrzcom_qso_upload_status,omitempty"`
// --- Contest ---
ContestID string `json:"contest_id,omitempty"`
@@ -164,6 +166,7 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
eqsl_sent, eqsl_rcvd, eqsl_sent_date, eqsl_rcvd_date,
clublog_qso_upload_date, clublog_qso_upload_status,
hrdlog_qso_upload_date, hrdlog_qso_upload_status,
qrzcom_qso_upload_date, qrzcom_qso_upload_status,
contest_id, srx, stx, srx_string, stx_string, check_field, precedence, arrl_sect,
prop_mode, sat_name, sat_mode, ant_az, ant_el, ant_path,
station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota,
@@ -215,6 +218,7 @@ func (q *QSO) args() []any {
q.EQSLSent, q.EQSLRcvd, q.EQSLSentDate, q.EQSLRcvdDate,
q.ClublogUploadDate, q.ClublogUploadStatus,
q.HRDLogUploadDate, q.HRDLogUploadStatus,
q.QRZComUploadDate, q.QRZComUploadStatus,
q.ContestID, q.SRX, q.STX, q.SRXString, q.STXString, q.Check, q.Precedence, q.ARRLSect,
q.PropMode, q.SatName, q.SatMode, q.AntAz, q.AntEl, q.AntPath,
q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA,
@@ -319,6 +323,34 @@ func (r *Repo) GetByID(ctx context.Context, id int64) (QSO, error) {
return scanQSO(row)
}
// MarkQRZUploaded stamps QRZCOM_QSO_UPLOAD_STATUS=Y and the upload date on
// a QSO after a successful push to the QRZ.com logbook. date is an ADIF
// YYYYMMDD string. Only the two QRZ columns are touched — no full-row
// rewrite — so it's safe to call from the async upload path.
func (r *Repo) MarkQRZUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET qrzcom_qso_upload_status = 'Y', qrzcom_qso_upload_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
date, id)
if err != nil {
return fmt.Errorf("mark qrz uploaded %d: %w", id, err)
}
return nil
}
// MarkClublogUploaded stamps CLUBLOG_QSO_UPLOAD_STATUS=Y and the upload
// date after a successful Club Log push. date is an ADIF YYYYMMDD string.
func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE qso SET clublog_qso_upload_status = 'Y', clublog_qso_upload_date = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
date, id)
if err != nil {
return fmt.Errorf("mark clublog uploaded %d: %w", id, err)
}
return nil
}
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
func (r *Repo) Update(ctx context.Context, q QSO) error {
if q.ID == 0 {
@@ -1004,6 +1036,7 @@ func scanQSO(s scanner) (QSO, error) {
eqslSentDate, eqslRcvdDate sql.NullString
clublogDate, clublogStatus sql.NullString
hrdlogDate, hrdlogStatus sql.NullString
qrzcomDate, qrzcomStatus sql.NullString
contestID sql.NullString
srx, stx sql.NullInt64
srxStr, stxStr sql.NullString
@@ -1035,6 +1068,7 @@ func scanQSO(s scanner) (QSO, error) {
&eqslSent, &eqslRcvd, &eqslSentDate, &eqslRcvdDate,
&clublogDate, &clublogStatus,
&hrdlogDate, &hrdlogStatus,
&qrzcomDate, &qrzcomStatus,
&contestID, &srx, &stx, &srxStr, &stxStr, &checkField, &precedence, &arrlSect,
&propMode, &satName, &satMode, &antAz, &antEl, &antPath,
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
@@ -1120,6 +1154,8 @@ func scanQSO(s scanner) (QSO, error) {
q.ClublogUploadStatus = clublogStatus.String
q.HRDLogUploadDate = hrdlogDate.String
q.HRDLogUploadStatus = hrdlogStatus.String
q.QRZComUploadDate = qrzcomDate.String
q.QRZComUploadStatus = qrzcomStatus.String
q.ContestID = contestID.String
if srx.Valid {
v := int(srx.Int64)