External services (QRZ/Clublog/LoTW) + QSL Manager
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,7 +60,8 @@ const (
|
||||
type ServiceConfig struct {
|
||||
APIKey string `json:"api_key"`
|
||||
Email string `json:"email"` // Club Log account email
|
||||
Password string `json:"password"` // Club Log account password
|
||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||
Password string `json:"password"` // Club Log account / LoTW website password
|
||||
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
|
||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ
|
||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -11,6 +14,60 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// lotwReportURL is LoTW's confirmation-report endpoint. It returns an ADIF
|
||||
// document of the user's QSOs (optionally only confirmed ones).
|
||||
const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
||||
|
||||
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
|
||||
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
|
||||
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
|
||||
// returned — used for incremental "Last download" updates.
|
||||
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
|
||||
user := strings.TrimSpace(cfg.Username)
|
||||
if user == "" || cfg.Password == "" {
|
||||
return "", fmt.Errorf("lotw: website login (username/password) not set")
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("login", user)
|
||||
q.Set("password", cfg.Password)
|
||||
q.Set("qso_query", "1")
|
||||
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
|
||||
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
|
||||
if s := strings.TrimSpace(since); s != "" {
|
||||
q.Set("qso_qslsince", s)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, lotwReportURL+"?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lotw: build request: %w", err)
|
||||
}
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 120 * time.Second}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lotw: request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lotw: read response: %w", err)
|
||||
}
|
||||
text := string(body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("lotw: http %d", resp.StatusCode)
|
||||
}
|
||||
// LoTW returns a plain-text error (not ADIF) on bad login.
|
||||
if !strings.Contains(strings.ToUpper(text), "<EOH>") && !strings.Contains(strings.ToLower(text), "<eor>") {
|
||||
msg := strings.TrimSpace(text)
|
||||
if len(msg) > 200 {
|
||||
msg = msg[:200]
|
||||
}
|
||||
return "", fmt.Errorf("lotw: unexpected response: %s", msg)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// LoTW uploads go through TQSL (ARRL's Trusted QSL signer): there is no
|
||||
// plain HTTP API — every QSO must be signed with the station certificate
|
||||
// before LoTW accepts it. We write the QSO to a temporary ADIF file and run
|
||||
|
||||
Reference in New Issue
Block a user