feat: upload qrz.com clublog and lotw manually
This commit is contained in:
@@ -30,7 +30,7 @@ type Service string
|
||||
const (
|
||||
ServiceQRZ Service = "qrz" // QRZ.com Logbook
|
||||
ServiceClublog Service = "clublog" // Club Log real-time upload
|
||||
// ServiceLoTW to come.
|
||||
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
||||
)
|
||||
|
||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||
@@ -41,6 +41,10 @@ const (
|
||||
ModeImmediate UploadMode = "immediate"
|
||||
// ModeDelayed waits a random 1–2 minutes before uploading.
|
||||
ModeDelayed UploadMode = "delayed"
|
||||
// ModeOnClose queues QSOs and uploads them in one batch when the app
|
||||
// closes. This is the LoTW-friendly mode (ARRL discourages per-QSO
|
||||
// uploads), and it lets the user fix the whole session before sending.
|
||||
ModeOnClose UploadMode = "on_close"
|
||||
)
|
||||
|
||||
// ServiceConfig is the per-service user configuration. It's a superset of
|
||||
@@ -49,6 +53,7 @@ const (
|
||||
//
|
||||
// QRZ.com → APIKey, ForceStationCallsign
|
||||
// Club Log → Email, Password, Callsign, APIKey
|
||||
// LoTW → TQSLPath, StationLocation, KeyPassword (signs+uploads via TQSL)
|
||||
//
|
||||
// AutoUpload + UploadMode are common to all (timing is per-service, so the
|
||||
// user can run e.g. Club Log immediate and QRZ delayed).
|
||||
@@ -58,6 +63,11 @@ type ServiceConfig struct {
|
||||
Password string `json:"password"` // Club Log account 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
|
||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||||
UploadFlag string `json:"upload_flag"` // LoTW: sent status that means "ready to upload" — "N" or "R"
|
||||
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
||||
AutoUpload bool `json:"auto_upload"`
|
||||
UploadMode UploadMode `json:"upload_mode"`
|
||||
}
|
||||
@@ -69,17 +79,29 @@ func (c ServiceConfig) normalised() ServiceConfig {
|
||||
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.TQSLPath = strings.TrimSpace(c.TQSLPath)
|
||||
c.StationLocation = strings.TrimSpace(c.StationLocation)
|
||||
// Upload flag is the LoTW sent-status that marks a QSO ready to upload.
|
||||
// Only "N" (no) and "R" (requested) are valid; default to "R".
|
||||
if uf := strings.ToUpper(strings.TrimSpace(c.UploadFlag)); uf == "N" || uf == "R" {
|
||||
c.UploadFlag = uf
|
||||
} else {
|
||||
c.UploadFlag = "R"
|
||||
}
|
||||
switch c.UploadMode {
|
||||
case ModeDelayed, ModeOnClose:
|
||||
// keep
|
||||
default:
|
||||
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"`
|
||||
LoTW ServiceConfig `json:"lotw"`
|
||||
}
|
||||
|
||||
// UploadResult is the outcome of a single upload attempt.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package extsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
// tqsl in batch mode to sign and upload it in one shot.
|
||||
|
||||
// StationLocation is one TQSL "Station Location" the user has defined. These
|
||||
// pair a callsign with a certificate + grid/zones; the upload picks one by
|
||||
// name (the -l flag).
|
||||
type StationLocation struct {
|
||||
Name string `json:"name"`
|
||||
Call string `json:"call"`
|
||||
Grid string `json:"grid"`
|
||||
DXCC int `json:"dxcc"`
|
||||
}
|
||||
|
||||
// stationDataFile mirrors TQSL's station_data XML.
|
||||
type stationDataFile struct {
|
||||
XMLName xml.Name `xml:"StationDataFile"`
|
||||
Stations []struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Call string `xml:"CALL"`
|
||||
Grid string `xml:"GRIDSQUARE"`
|
||||
DXCC int `xml:"DXCC"`
|
||||
} `xml:"StationData"`
|
||||
}
|
||||
|
||||
// ListStationLocations parses TQSL's station_data file and returns the
|
||||
// defined locations. Used to populate the Station Location dropdown — the
|
||||
// same file Log4OM reads.
|
||||
func ListStationLocations(stationDataPath string) ([]StationLocation, error) {
|
||||
data, err := os.ReadFile(stationDataPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read station_data: %w", err)
|
||||
}
|
||||
var f stationDataFile
|
||||
if err := xml.Unmarshal(data, &f); err != nil {
|
||||
return nil, fmt.Errorf("parse station_data: %w", err)
|
||||
}
|
||||
out := make([]StationLocation, 0, len(f.Stations))
|
||||
for _, s := range f.Stations {
|
||||
out = append(out, StationLocation{Name: s.Name, Call: s.Call, Grid: s.Grid, DXCC: s.DXCC})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DefaultTQSLPath returns the usual tqsl.exe install path on Windows, or ""
|
||||
// if not found.
|
||||
func DefaultTQSLPath() string {
|
||||
for _, p := range []string{
|
||||
`C:\Program Files (x86)\TrustedQSL\tqsl.exe`,
|
||||
`C:\Program Files\TrustedQSL\tqsl.exe`,
|
||||
} {
|
||||
if fileExists(p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DefaultStationDataPath returns TQSL's station_data location (%APPDATA%\
|
||||
// TrustedQSL\station_data on Windows), or "" if APPDATA isn't set.
|
||||
func DefaultStationDataPath() string {
|
||||
if appData := os.Getenv("APPDATA"); appData != "" {
|
||||
return filepath.Join(appData, "TrustedQSL", "station_data")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fileExists(p string) bool {
|
||||
info, err := os.Stat(p)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// UploadLoTW signs and uploads one ADIF record via TQSL. tempDir is where the
|
||||
// temporary .adi is written (falls back to the OS temp dir). Returns OK when
|
||||
// LoTW accepts the QSO or reports it as a duplicate (already uploaded).
|
||||
//
|
||||
// TQSL command:
|
||||
//
|
||||
// tqsl -d -x -a all -l "<location>" -u [-p <keypass>] <file.adi>
|
||||
//
|
||||
// Exit codes (TQSL): 0 = uploaded; 8 = nothing new (all duplicates/out of
|
||||
// range); 9 = some uploaded, some skipped; anything else = failure.
|
||||
func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) {
|
||||
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
||||
loc := strings.TrimSpace(cfg.StationLocation)
|
||||
switch {
|
||||
case tqsl == "":
|
||||
return UploadResult{}, fmt.Errorf("lotw: TQSL path not set")
|
||||
case !fileExists(tqsl):
|
||||
return UploadResult{}, fmt.Errorf("lotw: tqsl.exe not found at %q", tqsl)
|
||||
case loc == "":
|
||||
return UploadResult{}, fmt.Errorf("lotw: station location not set")
|
||||
case strings.TrimSpace(adifRecord) == "":
|
||||
return UploadResult{}, fmt.Errorf("lotw: empty adif record")
|
||||
}
|
||||
|
||||
// Write the QSO to a temp ADIF file (minimal header keeps strict TQSL
|
||||
// happy). Cleaned up after upload.
|
||||
if strings.TrimSpace(tempDir) == "" {
|
||||
tempDir = os.TempDir()
|
||||
}
|
||||
f, err := os.CreateTemp(tempDir, "opslog-lotw-*.adi")
|
||||
if err != nil {
|
||||
return UploadResult{}, fmt.Errorf("lotw: create temp file: %w", err)
|
||||
}
|
||||
tmpPath := f.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if _, err := f.WriteString("OpsLog LoTW upload\n<PROGRAMID:6>OpsLog <EOH>\n" + adifRecord + "\n"); err != nil {
|
||||
f.Close()
|
||||
return UploadResult{}, fmt.Errorf("lotw: write temp file: %w", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
args := []string{"-d", "-x", "-a", "all", "-l", loc, "-u"}
|
||||
if pwd := strings.TrimSpace(cfg.KeyPassword); pwd != "" {
|
||||
args = append(args, "-p", pwd)
|
||||
}
|
||||
if cfg.WriteLog {
|
||||
// -t writes a TQSL diagnostic log; drop it next to the temp ADIF.
|
||||
args = append(args, "-t", filepath.Join(tempDir, "opslog-tqsl.log"))
|
||||
}
|
||||
args = append(args, tmpPath)
|
||||
|
||||
// TQSL launches a child process and contacts LoTW — give it generous
|
||||
// time, independent of any short caller deadline.
|
||||
runCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
_ = ctx
|
||||
|
||||
cmd := exec.CommandContext(runCtx, tqsl, args...)
|
||||
out, runErr := cmd.CombinedOutput()
|
||||
msg := strings.TrimSpace(string(out))
|
||||
|
||||
code := 0
|
||||
if runErr != nil {
|
||||
if ee, ok := runErr.(*exec.ExitError); ok {
|
||||
code = ee.ExitCode()
|
||||
} else {
|
||||
return UploadResult{}, fmt.Errorf("lotw: run tqsl: %w", runErr)
|
||||
}
|
||||
}
|
||||
|
||||
switch code {
|
||||
case 0, 9:
|
||||
return UploadResult{OK: true, Message: "uploaded to LoTW"}, nil
|
||||
case 8:
|
||||
return UploadResult{OK: true, Message: "already uploaded (duplicate)"}, nil
|
||||
default:
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("tqsl exit code %d", code)
|
||||
}
|
||||
return UploadResult{OK: false, Message: msg}, fmt.Errorf("lotw: tqsl failed (code %d): %s", code, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoTW validates the LoTW config: tqsl present and the chosen station
|
||||
// location exists in station_data.
|
||||
func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) {
|
||||
tqsl := strings.TrimSpace(cfg.TQSLPath)
|
||||
loc := strings.TrimSpace(cfg.StationLocation)
|
||||
if tqsl == "" || !fileExists(tqsl) {
|
||||
return "", fmt.Errorf("lotw: tqsl.exe not found (set the TQSL path)")
|
||||
}
|
||||
if loc == "" {
|
||||
return "", fmt.Errorf("lotw: pick a station location")
|
||||
}
|
||||
locs, err := ListStationLocations(stationDataPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lotw: can't read station locations: %w", err)
|
||||
}
|
||||
for _, l := range locs {
|
||||
if strings.EqualFold(l.Name, loc) {
|
||||
return fmt.Sprintf("Ready — TQSL found, location %q (%s)", l.Name, l.Call), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("lotw: station location %q not found in TQSL", loc)
|
||||
}
|
||||
+144
-15
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -26,6 +27,12 @@ type Deps struct {
|
||||
// NotifyError surfaces a failed upload (logging + optional UI event).
|
||||
NotifyError func(svc Service, id int64, err error)
|
||||
|
||||
// ShouldUpload reports whether a QSO is eligible for upload to this
|
||||
// service, based on its sent status: QRZ/Club Log upload anything not
|
||||
// yet "Y"; LoTW uploads only QSOs whose lotw_sent matches the configured
|
||||
// Upload flag ("N" or "R"), à la Log4OM. Returning false skips the QSO.
|
||||
ShouldUpload func(svc Service, id int64) bool
|
||||
|
||||
// Logf is an optional diagnostic logger.
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
@@ -36,9 +43,10 @@ type Deps struct {
|
||||
type Manager struct {
|
||||
deps Deps
|
||||
|
||||
mu sync.Mutex
|
||||
cfg ExternalServices
|
||||
rnd *rand.Rand
|
||||
mu sync.Mutex
|
||||
cfg ExternalServices
|
||||
rnd *rand.Rand
|
||||
pending map[Service][]int64 // QSO ids queued for ModeOnClose upload
|
||||
}
|
||||
|
||||
func NewManager(deps Deps) *Manager {
|
||||
@@ -49,7 +57,8 @@ func NewManager(deps Deps) *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())),
|
||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
pending: map[Service][]int64{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,13 +100,30 @@ func (m *Manager) OnQSOLogged(id int64) {
|
||||
|
||||
// QRZ.com
|
||||
if qrz := cfg.QRZ; qrz.AutoUpload && qrz.APIKey != "" {
|
||||
m.scheduleUpload(ServiceQRZ, id, qrz)
|
||||
m.route(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)
|
||||
m.route(ServiceClublog, id, cl)
|
||||
}
|
||||
// LoTW will be added here.
|
||||
// LoTW — needs TQSL + a station location.
|
||||
if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" {
|
||||
m.route(ServiceLoTW, id, lt)
|
||||
}
|
||||
}
|
||||
|
||||
// route sends a logged QSO down the configured timing path: queue it for the
|
||||
// app-close batch, or schedule an immediate / delayed upload.
|
||||
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
||||
if cfg.UploadMode == ModeOnClose {
|
||||
m.mu.Lock()
|
||||
m.pending[svc] = append(m.pending[svc], id)
|
||||
n := len(m.pending[svc])
|
||||
m.mu.Unlock()
|
||||
m.logf("extsvc: %s queued QSO %d for on-close upload (%d pending)", svc, id, n)
|
||||
return
|
||||
}
|
||||
m.scheduleUpload(svc, id, cfg)
|
||||
}
|
||||
|
||||
// scheduleUpload either uploads now (immediate) or arms a timer (delayed).
|
||||
@@ -111,10 +137,104 @@ func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||
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) {
|
||||
// PendingCount returns how many QSOs are queued for on-close upload across
|
||||
// all services. The shutdown sequence uses it to decide whether to show the
|
||||
// upload step.
|
||||
func (m *Manager) PendingCount() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
n := 0
|
||||
for _, ids := range m.pending {
|
||||
n += len(ids)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// FlushOnClose uploads every queued QSO. Called from the shutdown sequence.
|
||||
// QRZ/Club Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a
|
||||
// single TQSL batch. Returns the number of QSOs uploaded successfully.
|
||||
func (m *Manager) FlushOnClose() int {
|
||||
m.mu.Lock()
|
||||
pending := m.pending
|
||||
m.pending = map[Service][]int64{}
|
||||
cfg := m.cfg
|
||||
m.mu.Unlock()
|
||||
|
||||
uploaded := 0
|
||||
for svc, ids := range pending {
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
switch svc {
|
||||
case ServiceLoTW:
|
||||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||||
default:
|
||||
var sc ServiceConfig
|
||||
switch svc {
|
||||
case ServiceQRZ:
|
||||
sc = cfg.QRZ
|
||||
case ServiceClublog:
|
||||
sc = cfg.Clublog
|
||||
}
|
||||
for _, id := range ids {
|
||||
if m.upload(svc, id, sc) {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return uploaded
|
||||
}
|
||||
|
||||
// flushLoTWBatch signs+uploads all queued LoTW QSOs in one TQSL run, then
|
||||
// stamps each as uploaded on success.
|
||||
func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
|
||||
var records []string
|
||||
var kept []int64
|
||||
for _, id := range ids {
|
||||
// Skip QSOs not eligible (sent status doesn't match Upload flag).
|
||||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceLoTW, id) {
|
||||
continue
|
||||
}
|
||||
if rec, ok := m.deps.BuildADIF(id, ""); ok {
|
||||
records = append(records, rec)
|
||||
kept = append(kept, id)
|
||||
}
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return 0
|
||||
}
|
||||
res, err := UploadLoTW(context.Background(), cfg, "", strings.Join(records, "\n"))
|
||||
if err != nil || !res.OK {
|
||||
if err == nil {
|
||||
err = errFromResult(res)
|
||||
}
|
||||
m.logf("extsvc: lotw batch upload (%d QSOs) failed: %v", len(kept), err)
|
||||
if m.deps.NotifyError != nil {
|
||||
m.deps.NotifyError(ServiceLoTW, 0, err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
m.logf("extsvc: lotw batch upload OK (%d QSOs)", len(kept))
|
||||
if m.deps.MarkUploaded != nil {
|
||||
for _, id := range kept {
|
||||
m.deps.MarkUploaded(ServiceLoTW, id, res.LogID)
|
||||
}
|
||||
}
|
||||
return len(kept)
|
||||
}
|
||||
|
||||
// upload performs the actual push and returns true on success. 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) bool {
|
||||
// Skip QSOs that aren't eligible (already sent, or sent status doesn't
|
||||
// match the configured Upload flag).
|
||||
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(svc, id) {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (not eligible)", svc, id)
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -126,7 +246,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
|
||||
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return
|
||||
return false
|
||||
}
|
||||
res, err = UploadQRZ(ctx, m.deps.Client, cfg.APIKey, record)
|
||||
case ServiceClublog:
|
||||
@@ -135,11 +255,19 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return
|
||||
return false
|
||||
}
|
||||
res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
|
||||
case ServiceLoTW:
|
||||
// LoTW signs the QSO's own station call via TQSL — no override.
|
||||
record, ok := m.deps.BuildADIF(id, "")
|
||||
if !ok {
|
||||
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||
return false
|
||||
}
|
||||
res, err = UploadLoTW(ctx, cfg, "", record)
|
||||
default:
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
if err != nil || !res.OK {
|
||||
@@ -150,11 +278,12 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) {
|
||||
if m.deps.NotifyError != nil {
|
||||
m.deps.NotifyError(svc, id, err)
|
||||
}
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user