feat(hamqth): QSO upload to the HamQTH logbook — the 8th external service

qso_realtime.php with the account's callbook credentials (which the
external-services config falls back to when its own are blank), one ADIF
record per QSO, prg=OpsLog. HTTP status IS the answer: 200 saved, 400
rejected (a duplicate counts as delivered, like HRDLog's insert 0), 403
credentials. Sent-state lives in APP_OPSLOG_HAMQTH_SENT extras like
HAMLOG.online — ADIF names no HamQTH field. Auto-upload on log, on-close
batch, right-click Send to, QSL Manager backlog, and a Test button that
authenticates against the callbook login, which cannot touch the log.

Fixes a real mis-route on the way: manual 'Send to HAMLOG.online' had no
branch in runManualUpload and fell through to QRZ.com — the selection was
uploaded to the wrong service with the QRZ key. Both extras-stamped
services now have their own branch.
This commit is contained in:
2026-08-31 14:34:49 +02:00
parent 68f0d68980
commit 91569e12f4
13 changed files with 328 additions and 12 deletions
+4
View File
@@ -38,6 +38,9 @@ const (
ServiceCloudlog Service = "cloudlog"
// ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO.
ServiceHamlog Service = "hamlog"
// ServiceHamQTH is the HamQTH online logbook — the callbook credentials,
// one ADIF record per QSO.
ServiceHamQTH Service = "hamqth"
)
// UploadMode selects when an auto-upload fires after a QSO is saved.
@@ -133,6 +136,7 @@ type ExternalServices struct {
EQSL ServiceConfig `json:"eqsl"`
Cloudlog ServiceConfig `json:"cloudlog"`
Hamlog ServiceConfig `json:"hamlog"`
HamQTH ServiceConfig `json:"hamqth"`
// DeleteRemote asks OpsLog to withdraw a QSO from QRZ.com and Club Log when
// it is deleted locally. Off unless the operator turns it on: neither
+129
View File
@@ -0,0 +1,129 @@
package extsvc
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// HamQTH real-time QSO upload.
//
// One POST per QSO to qso_realtime.php with the account username/password —
// the same credentials the HamQTH callbook lookup uses. The answer is the
// HTTP status code, not a body format: 200 saved, 400 rejected (bad band,
// duplicate…, reason in the body), 403 wrong credentials, 500 server error.
const hamqthUploadURL = "https://www.hamqth.com/qso_realtime.php"
// hamqthLoginURL is the callbook session login — the one authenticated HamQTH
// endpoint that cannot change anything in the log, which is what the settings
// Test button must call.
const hamqthLoginURL = "https://www.hamqth.com/xml.php"
// UploadHamQTH pushes one ADIF record to the HamQTH online logbook.
func UploadHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
return uploadHamQTHTo(ctx, client, hamqthUploadURL, cfg, adifRecord)
}
func uploadHamQTHTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
user := strings.TrimSpace(cfg.Username)
switch {
case user == "":
return UploadResult{}, fmt.Errorf("hamqth: username not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("hamqth: password not set")
}
rec := strings.TrimSpace(adifRecord)
if rec == "" {
return UploadResult{}, fmt.Errorf("hamqth: empty ADIF record")
}
form := url.Values{}
form.Set("u", user)
form.Set("p", cfg.Password)
// c: the logbook callsign when the account holds several; empty means the
// account's own call, which is the common case.
if c := strings.ToUpper(strings.TrimSpace(cfg.Callsign)); c != "" {
form.Set("c", c)
}
form.Set("adif", rec)
form.Set("prg", "OpsLog")
form.Set("cmd", "insert")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return UploadResult{}, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
msg := strings.TrimSpace(string(body))
switch resp.StatusCode {
case http.StatusOK:
return UploadResult{OK: true}, nil
case http.StatusBadRequest:
// "Rejected" covers duplicates too. A duplicate is a SUCCESS for our
// purposes — the QSO is in the logbook, retrying it forever isn't —
// same treatment HRDLog's <insert>0 gets.
if strings.Contains(strings.ToLower(msg), "dupl") {
return UploadResult{OK: true, Ignored: true, Message: "already in logbook"}, nil
}
if msg == "" {
msg = "QSO rejected"
}
return UploadResult{OK: false, Message: msg}, nil
case http.StatusForbidden:
return UploadResult{}, fmt.Errorf("hamqth: wrong username or password")
default:
if msg != "" && len(msg) < 200 {
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d: %s", resp.StatusCode, msg)
}
return UploadResult{}, fmt.Errorf("hamqth: HTTP %d", resp.StatusCode)
}
}
// TestHamQTH verifies the credentials against the callbook session login —
// authenticated, and unable to touch the log.
func TestHamQTH(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
user := strings.TrimSpace(cfg.Username)
if user == "" || cfg.Password == "" {
return "", fmt.Errorf("hamqth: set the username and password first")
}
q := url.Values{}
q.Set("u", user)
q.Set("p", cfg.Password)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hamqthLoginURL+"?"+q.Encode(), nil)
if err != nil {
return "", err
}
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
s := string(body)
if strings.Contains(s, "<session_id>") {
return fmt.Sprintf("Connected to HamQTH as %s.", user), nil
}
if i := strings.Index(s, "<error>"); i >= 0 {
e := s[i+len("<error>"):]
if j := strings.Index(e, "</error>"); j >= 0 {
return "", fmt.Errorf("hamqth: %s", strings.TrimSpace(e[:j]))
}
}
return "", fmt.Errorf("hamqth: unexpected answer — check the username and password")
}
+25 -2
View File
@@ -140,6 +140,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
cfg.EQSL = cfg.EQSL.normalised()
cfg.Cloudlog = cfg.Cloudlog.normalised()
cfg.Hamlog = cfg.Hamlog.normalised()
cfg.HamQTH = cfg.HamQTH.normalised()
m.cfg = cfg
// Summary of what is armed, written at startup and on every settings save.
@@ -153,7 +154,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
}{
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
{"hamlog", cfg.Hamlog},
{"hamlog", cfg.Hamlog}, {"hamqth", cfg.HamQTH},
} {
if s.cfg.AutoUpload {
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
@@ -237,6 +238,14 @@ func (m *Manager) OnQSOLogged(id int64) {
m.route(ServiceHamlog, id, h)
}
}
// HamQTH — the callbook credentials double as the logbook login.
if h := cfg.HamQTH; h.AutoUpload {
if h.Username == "" || h.Password == "" {
m.logf("extsvc: hamqth auto-upload is ON but the username/password is not set (QSO %d not sent)", id)
} else {
m.route(ServiceHamQTH, id, h)
}
}
}
// route sends a logged QSO down the configured timing path: queue it for the
@@ -290,6 +299,9 @@ func (m *Manager) onCloseServices() []Service {
if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" {
out = append(out, ServiceHamlog)
}
if h := cfg.HamQTH; h.AutoUpload && h.UploadMode == ModeOnClose && h.Username != "" && h.Password != "" {
out = append(out, ServiceHamQTH)
}
return out
}
@@ -338,6 +350,8 @@ func (m *Manager) FlushOnClose() int {
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
case ServiceHamlog:
uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog)
case ServiceHamQTH:
uploaded += m.flushOneByOne(svc, ids, cfg.HamQTH)
}
}
return uploaded
@@ -577,7 +591,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
switch svc {
case ServiceQRZ, ServiceLoTW:
owner = cfg.ForceStationCallsign
case ServiceClublog, ServiceHRDLog:
case ServiceClublog, ServiceHRDLog, ServiceHamQTH:
owner = cfg.Callsign
case ServiceEQSL:
owner = cfg.Username
@@ -669,6 +683,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
return false, false
}
res, err = UploadHamlog(ctx, m.deps.Client, cfg, record)
case ServiceHamQTH:
// The c parameter names the logbook when the account holds several;
// the QSO keeps its own STATION_CALLSIGN in the ADIF.
record, ok := m.deps.BuildADIF(id, "")
if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false, false
}
res, err = UploadHamQTH(ctx, m.deps.Client, cfg, record)
default:
return false, false
}