Files
OpsLog/internal/extsvc/extsvc.go
rouggy ee148732dd feat: HAMLOG.online upload and confirmations, Yaesu antenna, a named port holder
HAMLOG.online is a seventh external service: one API key, one ADIF record per
QSO, immediate / delayed / on-close like the rest. They publish no API
documentation, so the protocol is read from THEIR OWN client — the HAMLOG Agent
(github.com/hamlogonline/Agent), which is the authoritative source short of
asking them:

	POST https://hamlog.online/api/agent/
	{"ADIFADD": {"APIKEY": k, "ADIFDATA": record}}   → {"STATUS":"OK"}
	{"KEYSTATUS": {"APIKEY": k}}                     → {"STATUS":"OK","CALLSIGN":…}

Success is STATUS == OK, not "no ERROR field": their failure carries ERROR and
no STATUS, and reading an unknown reply — a proxy page, a maintenance notice —
as an acceptance is how a contact goes missing without anyone noticing.

KEYSTATUS buys something no other service here offers: the key can be checked
BEFORE the first QSO, and the answer names the account. A key pasted from
another callsign is caught in the settings panel rather than through a week of
silent refusals.

Their confirmations are also an award source now, ticked like LoTW rather than
expressed through "custom". It reads the ADIF extras, not a column: the standard
names a field for hamlog.EU and none for hamlog.ONLINE, and borrowing the other
site's field would write a falsehood into every exported log. Three plausible
key names from their own export are accepted too, so nobody has to rename a
column by hand after an export.

Yaesu gains antenna selection (AN), remembered per band — the antenna picked on
a band comes back with it, with no table to fill in anywhere. Rigs with one
socket never answer AN and never show the row; the log says which case it is.

And a serial port that is refused now names its likely holder. OmniRig stays
resident once activated and keeps the port of the rig configured in it, so a
native backend never gets it — "Serial port busy" alone accused nobody, and an
FTDX10 spent a morning being blamed for it.
2026-08-23 12:18:27 +02:00

157 lines
7.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 Service = "lotw" // ARRL Logbook of The World (via TQSL)
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
// ServiceCloudlog covers Cloudlog AND its fork Wavelog: same API contract,
// only the instance URL differs, so one service handles both.
ServiceCloudlog Service = "cloudlog"
// ServiceHamlog is HAMLOG.online — one API key, an ADIF record per QSO.
ServiceHamlog Service = "hamlog"
)
// 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"
// 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
// 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
// LoTW → TQSLPath, StationLocation, ForceStationCallsign, KeyPassword
// (signs+uploads via TQSL; ForceStationCallsign overrides
// STATION_CALLSIGN so one cert can sign F4BPO / F4BPO/P / TM2Q)
//
// 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"`
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
Email string `json:"email"` // Club Log account email
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 / HRDLog logbook (owner) callsign
Code string `json:"code"` // HRDLog: account upload code
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
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)
UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R"
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
AutoUpload bool `json:"auto_upload"`
// OnAir: HRDLog only — publish the live frequency, mode and rig on the site.
OnAir bool `json:"on_air"`
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.URL = strings.TrimSpace(c.URL)
c.StationID = strings.TrimSpace(c.StationID)
c.Email = strings.TrimSpace(c.Email)
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
c.Code = strings.TrimSpace(c.Code)
c.Username = strings.TrimSpace(c.Username)
c.QTHNickname = strings.TrimSpace(c.QTHNickname)
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
c.StationLocation = strings.TrimSpace(c.StationLocation)
// Upload flags are the LoTW sent-statuses that mark a QSO ready to upload.
// Only "N" (no) and "R" (requested) are valid; clean + dedupe, no default
// here (the caller injects N+R when nothing is configured).
var flags []string
seen := map[string]bool{}
for _, f := range c.UploadFlags {
f = strings.ToUpper(strings.TrimSpace(f))
if (f == "N" || f == "R") && !seen[f] {
seen[f] = true
flags = append(flags, f)
}
}
c.UploadFlags = flags
switch c.UploadMode {
case ModeDelayed, ModeOnClose:
// keep
default:
c.UploadMode = ModeImmediate
}
return c
}
// ExternalServices bundles every service's config for the settings UI.
type ExternalServices struct {
QRZ ServiceConfig `json:"qrz"`
Clublog ServiceConfig `json:"clublog"`
LoTW ServiceConfig `json:"lotw"`
HRDLog ServiceConfig `json:"hrdlog"`
EQSL ServiceConfig `json:"eqsl"`
Cloudlog ServiceConfig `json:"cloudlog"`
Hamlog ServiceConfig `json:"hamlog"`
// 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
// service can undo it, and a local delete is not always meant to reach the
// world — a duplicate cleared from a contest log was never meant to be a
// statement about the DX's log.
DeleteRemote bool `json:"delete_remote"`
}
// 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)
// Ignored is set when the service accepted the submission but left some or
// all of the QSOs out of it. TQSL does this for contacts already uploaded
// AND for contacts outside the certificate's date range, and reports both
// with the same exit code — so the caller must show the message rather than
// decide on its own that everything went through.
Ignored bool
}