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
+92 -2
View File
@@ -401,6 +401,12 @@ const (
keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload"
keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode"
keyExtHamqthUsername = "extsvc.hamqth.username"
keyExtHamqthPassword = "extsvc.hamqth.password"
keyExtHamqthCallsign = "extsvc.hamqth.callsign"
keyExtHamqthAutoUpload = "extsvc.hamqth.auto_upload"
keyExtHamqthUploadMode = "extsvc.hamqth.upload_mode"
keyExtHamlogAPIKey = "extsvc.hamlog.api_key"
keyExtHamlogAutoUpload = "extsvc.hamlog.auto_upload"
keyExtHamlogUploadMode = "extsvc.hamlog.upload_mode"
@@ -11177,7 +11183,9 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode, keyExtDeleteRemote,
keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode)
keyExtHamlogAPIKey, keyExtHamlogAutoUpload, keyExtHamlogUploadMode,
keyExtHamqthUsername, keyExtHamqthPassword, keyExtHamqthCallsign,
keyExtHamqthAutoUpload, keyExtHamqthUploadMode)
if err != nil {
return out
}
@@ -11261,6 +11269,21 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
AutoUpload: m[keyExtHamlogAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtHamlogUploadMode]),
}
out.HamQTH = extsvc.ServiceConfig{
Username: m[keyExtHamqthUsername],
Password: m[keyExtHamqthPassword],
Callsign: m[keyExtHamqthCallsign],
AutoUpload: m[keyExtHamqthAutoUpload] == "1",
UploadMode: extsvc.UploadMode(m[keyExtHamqthUploadMode]),
}
// The callbook lookup already knows these credentials; blanks fall back to
// them so an operator who set up the lookup years ago is one checkbox away.
if out.HamQTH.Username == "" && out.HamQTH.Password == "" {
u, _ := a.settings.Get(a.ctx, keyHQUser)
p, _ := a.settings.Get(a.ctx, keyHQPassword)
out.HamQTH.Username = strings.TrimSpace(u)
out.HamQTH.Password = p
}
return out
}
@@ -11335,6 +11358,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
if hamMode == string(extsvc.ModeOnClose) {
hamMode = string(extsvc.ModeImmediate)
}
hqMode := modeOf(cfg.HamQTH.UploadMode)
hamAuto := "0"
if cfg.Hamlog.AutoUpload {
hamAuto = "1"
@@ -11390,6 +11414,11 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
keyExtHamlogAPIKey: strings.TrimSpace(cfg.Hamlog.APIKey),
keyExtHamlogAutoUpload: hamAuto,
keyExtHamlogUploadMode: hamMode,
keyExtHamqthUsername: strings.TrimSpace(cfg.HamQTH.Username),
keyExtHamqthPassword: cfg.HamQTH.Password,
keyExtHamqthCallsign: strings.ToUpper(strings.TrimSpace(cfg.HamQTH.Callsign)),
keyExtHamqthAutoUpload: boolStr(cfg.HamQTH.AutoUpload),
keyExtHamqthUploadMode: hqMode,
} {
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
return err
@@ -11418,6 +11447,13 @@ func (a *App) TestClublogUpload() (string, error) {
return extsvc.TestClublog(a.ctx, a.loadExternalServices().Clublog)
}
// TestHamQTHUpload checks the HamQTH credentials against the callbook login —
// authenticated, and unable to touch the log.
func (a *App) TestHamQTHUpload() (string, error) {
cfg := a.loadExternalServices().HamQTH
return extsvc.TestHamQTH(a.ctx, nil, cfg)
}
// TestHRDLogUpload validates that the HRDLog credentials are complete.
func (a *App) TestHRDLogUpload() (string, error) {
return extsvc.TestHRDLog(a.ctx, nil, a.loadExternalServices().HRDLog)
@@ -11477,6 +11513,9 @@ func (a *App) FindQSOsForUpload(service, sentStatus string) ([]qso.QSO, error) {
if extsvc.Service(service) == extsvc.ServiceHamlog {
return a.qso.ListMissingExtra(a.ctx, hamlogSentKey)
}
if extsvc.Service(service) == extsvc.ServiceHamQTH {
return a.qso.ListMissingExtra(a.ctx, hamqthSentKey)
}
col := uploadColumnFor(service)
if col == "" {
return nil, fmt.Errorf("unknown service %q", service)
@@ -11492,7 +11531,7 @@ func (a *App) UploadQSOsManual(service string, ids []int64) error {
return fmt.Errorf("db not initialized")
}
svc := extsvc.Service(service)
if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog {
if uploadColumnFor(service) == "" && svc != extsvc.ServiceHamlog && svc != extsvc.ServiceHamQTH {
return fmt.Errorf("unknown service %q", service)
}
cfg := a.loadExternalServices()
@@ -11716,6 +11755,43 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
}
flush()
}
} else if svc == extsvc.ServiceHamlog || svc == extsvc.ServiceHamQTH {
// One record per request, extras-stamped services. Hamlog used to fall
// through to the QRZ branch below and got uploaded to the WRONG service
// with the QRZ key — the context menu offered what the loop never handled.
for i, id := range ids {
if i > 0 {
time.Sleep(manualUploadPace)
}
q, gerr := a.qso.GetByID(ctx, id)
call := ""
if gerr == nil {
call = q.Callsign
}
rec, ok := a.buildUploadADIF(id, "")
if !ok {
emit(call + " — skipped (no record)")
continue
}
var res extsvc.UploadResult
var err error
if svc == extsvc.ServiceHamlog {
res, err = extsvc.UploadHamlog(ctx, nil, cfg.Hamlog, rec)
} else {
res, err = extsvc.UploadHamQTH(ctx, nil, cfg.HamQTH, rec)
}
if err == nil && res.OK {
a.markExtUploaded(svc, id, res.LogID)
uploaded++
emit(call + " — OK")
} else {
msg := res.Message
if err != nil {
msg = err.Error()
}
emit(call + " — FAILED: " + msg)
}
}
} else {
// QRZ.com: one record per request (its logbook API has no batch upload),
// paced for the same reason as HRDLog above.
@@ -11757,6 +11833,7 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
label := map[extsvc.Service]string{
extsvc.ServiceQRZ: "QRZ.com", extsvc.ServiceClublog: "Club Log", extsvc.ServiceHRDLog: "HRDLog",
extsvc.ServiceLoTW: "LoTW", extsvc.ServiceEQSL: "eQSL",
extsvc.ServiceHamlog: "HAMLOG.online", extsvc.ServiceHamQTH: "HamQTH",
}[svc]
if label == "" {
label = string(svc)
@@ -13500,6 +13577,13 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
return false
}
return true
case extsvc.ServiceHamQTH:
// Same extras stamp as HAMLOG.online — ADIF names no HamQTH field.
if q.Extras != nil && strings.TrimSpace(q.Extras[hamqthSentKey]) != "" {
applog.Printf("extsvc: QSO %d not eligible for hamqth — already sent on %s", id, q.Extras[hamqthSentKey])
return false
}
return true
case extsvc.ServiceLoTW:
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
if strings.EqualFold(q.LOTWSent, f) {
@@ -13520,6 +13604,8 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
const (
hamlogSentKey = "APP_OPSLOG_HAMLOG_SENT"
hamlogSentDateKey = "APP_OPSLOG_HAMLOG_SENT_DATE"
hamqthSentKey = "APP_OPSLOG_HAMQTH_SENT"
hamqthSentDateKey = "APP_OPSLOG_HAMQTH_SENT_DATE"
)
func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
@@ -13567,6 +13653,10 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
if err = a.qso.SetExtra(ctx, id, hamlogSentKey, "Y"); err == nil {
err = a.qso.SetExtra(ctx, id, hamlogSentDateKey, date)
}
case extsvc.ServiceHamQTH:
if err = a.qso.SetExtra(ctx, id, hamqthSentKey, "Y"); err == nil {
err = a.qso.SetExtra(ctx, id, hamqthSentDateKey, date)
}
}
if err != nil {
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)