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.
This commit is contained in:
2026-08-23 12:18:27 +02:00
parent 3014796c1d
commit ee148732dd
21 changed files with 603 additions and 18 deletions
+48 -1
View File
@@ -109,7 +109,7 @@ type Def struct {
Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all)
// --- Confirmation ---
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|hamlog|custom
Validate []string `json:"validate,omitempty"` // validated/granted sources
// The "custom" source, for confirmations OpsLog has no dedicated column for:
// ConfirmField names a QSO field or an ADIF extras key, ConfirmValue the
@@ -1481,6 +1481,18 @@ func confirmed(q *qso.QSO, sources []string, d *Def) bool {
if isYes(q.QRZComDownloadStatus) {
return true
}
case "hamlog":
// HAMLOG.online, a first-class source rather than something to express
// through "custom" — its confirmations feed its own award programme
// and operators reach for it the same way they reach for LoTW.
//
// Read from the ADIF EXTRAS, not a column: the ADIF standard names a
// field for hamlog.EU (HAMLOGEU_QSO_UPLOAD_STATUS) and none for
// hamlog.ONLINE, and borrowing the other site's field would write a
// falsehood into every exported log.
if hamlogConfirmed(q) {
return true
}
case "custom":
if customConfirmed(q, d) {
return true
@@ -1490,6 +1502,41 @@ func confirmed(q *qso.QSO, sources []string, d *Def) bool {
return false
}
// HamlogQSLKey is where a HAMLOG.online confirmation is recorded, as an ADIF
// extras key. Exported and re-imported like any other extra, so the state
// survives a move to another logger and back.
const HamlogQSLKey = "APP_OPSLOG_HAMLOG_QSL"
// hamlogAltKeys are the shapes an ADIF exported BY hamlog.online might use.
//
// Their site publishes no field name, so rather than demand that an operator
// rename a column by hand after every export, the ones an export could
// plausibly carry are accepted too. Costs three map lookups; saves a support
// thread that would end in "edit your ADIF".
var hamlogAltKeys = []string{"APP_HAMLOG_QSL", "APP_HAMLOGONLINE_QSL", "HAMLOG_QSL_RCVD"}
// hamlogConfirmed reports whether a QSO carries a HAMLOG.online confirmation.
//
// Any non-empty value counts, except an explicit "N": their export could carry
// a date, a Y, or a match id, and demanding one of them would silently confirm
// nothing on the two shapes we guessed wrong.
func hamlogConfirmed(q *qso.QSO) bool {
if q == nil || q.Extras == nil {
return false
}
for _, k := range append([]string{HamlogQSLKey}, hamlogAltKeys...) {
v := strings.TrimSpace(q.Extras[k])
if v == "" {
continue
}
if strings.EqualFold(v, "N") || strings.EqualFold(v, "NO") {
continue
}
return true
}
return false
}
// customConfirmed answers the operator-defined confirmation source: the field
// named by ConfirmField, optionally required to hold one of ConfirmValue's
// comma-separated values.
+31
View File
@@ -64,3 +64,34 @@ func TestConfirmedSources(t *testing.T) {
}
}
}
// HAMLOG.online is a named confirmation source, not something to express through
// "custom": an award ticks it the way it ticks LoTW.
func TestHamlogConfirmationSource(t *testing.T) {
def := Def{Confirm: []string{"hamlog"}, Validate: []string{"hamlog"}}
yes := func(extras map[string]string) bool {
return Confirmed(&qso.QSO{Extras: extras}, def, def.Confirm)
}
if !yes(map[string]string{HamlogQSLKey: "Y"}) {
t.Error("our own key did not confirm")
}
// Their export's field name is unpublished, so the plausible shapes count too.
if !yes(map[string]string{"APP_HAMLOG_QSL": "20260823"}) {
t.Error("a date in an alternative key did not confirm")
}
// A value is not a confirmation when it says no.
if yes(map[string]string{HamlogQSLKey: "N"}) {
t.Error(`"N" was taken as a confirmation`)
}
if yes(map[string]string{HamlogQSLKey: " "}) {
t.Error("blank was taken as a confirmation")
}
if yes(nil) || yes(map[string]string{"SOMETHING_ELSE": "Y"}) {
t.Error("confirmed with nothing to confirm it")
}
// And it does not leak into an award that never asked for it.
other := Def{Confirm: []string{"lotw"}}
if Confirmed(&qso.QSO{Extras: map[string]string{HamlogQSLKey: "Y"}}, other, other.Confirm) {
t.Error("a hamlog confirmation counted for an award that only accepts LoTW")
}
}
+1 -1
View File
@@ -189,7 +189,7 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I
}
p, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
if err != nil {
return nil, fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err)
return nil, fmt.Errorf("open %s @ %d baud: %w%s", b.portName, b.baud, err, busyHint(b.portName, err))
}
return p, nil
}
+1 -1
View File
@@ -210,7 +210,7 @@ func (k *Kenwood) Connect() error {
if k.host != "" {
return fmt.Errorf("kenwood: connect %s: %w", k.host, err)
}
return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err)
return fmt.Errorf("kenwood: open %s @ %d baud: %w%s", k.portName, k.baud, err, busyHint(k.portName, err))
}
p.SetReadTimeout(300 * time.Millisecond)
k.port = p
+39
View File
@@ -0,0 +1,39 @@
package cat
// "Serial port busy" is a true statement that helps nobody.
//
// A COM port has exactly one owner, and when a rig's port is refused the owner
// is almost always another program on the same desktop — most often OmniRig,
// which stays resident once any application has activated it and keeps the port
// of the rig configured in it. An operator switching from OmniRig to a native
// backend therefore hits this the moment they save: OpsLog is now asking for a
// port OmniRig never let go of. The message named none of that.
//
// It also happens the other way round, and the wording says so rather than
// accusing: a digital application (WSJT-X, JTDX, MSHV) configured on the rig's
// port directly, rather than through OpsLog's CAT sharing, holds it just as
// firmly.
import "strings"
// busyHint returns a sentence to append to an open error, or "" when the error
// is not about the port being taken.
//
// Deliberately not a wrapped error: this is advice for a human reading a log,
// and it must not change how any caller compares the error.
func busyHint(port string, err error) string {
if err == nil {
return ""
}
msg := strings.ToLower(err.Error())
busy := strings.Contains(msg, "busy") ||
strings.Contains(msg, "access is denied") ||
strings.Contains(msg, "denied") ||
strings.Contains(msg, "in use")
if !busy {
return ""
}
return " — another program holds " + port +
". OmniRig is the usual one (it stays running and keeps the port of the rig configured in it, so a native backend can never have it);" +
" a digital application pointed straight at the rig instead of at OpsLog's CAT sharing does the same. Close it, then reconnect."
}
+1 -1
View File
@@ -95,7 +95,7 @@ func (x *Xiegu) Connect() error {
}
p, err := serial.Open(x.portName, &serial.Mode{BaudRate: x.baud})
if err != nil {
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
return fmt.Errorf("xiegu: open %s @ %d baud: %w%s", x.portName, x.baud, err, busyHint(x.portName, err))
}
p.SetReadTimeout(200 * time.Millisecond)
// Deassert DTR and RTS.
+1 -1
View File
@@ -159,7 +159,7 @@ func (y *Yaesu) Connect() error {
}
p, err := serial.Open(y.portName, &serial.Mode{BaudRate: y.baud})
if err != nil {
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
return fmt.Errorf("yaesu: open %s @ %d baud: %w%s", y.portName, y.baud, err, busyHint(y.portName, err))
}
// The modem lines are only touched when the operator asks for it — see the
// note on Kenwood.lowerLines. Both defaults break somebody's station.
+39 -5
View File
@@ -50,11 +50,15 @@ type YaesuTXState struct {
Squelch int `json:"squelch"` // 0-100
AGC string `json:"agc,omitempty"`
Preamp int `json:"preamp"` // 0=IPO, 1=AMP1, 2=AMP2
Att int `json:"att"` // 0=off, else dB
NB bool `json:"nb"`
NR bool `json:"nr"`
NRLevel int `json:"nr_level"` // 1-15
Narrow bool `json:"narrow"` // NAR filter
// Antenna is the selected jack, 1-3, or 0 when the rig has no AN command —
// an FT-891 or FT-991A has a single socket and answers nothing. 0 is what
// tells the panel to draw no selector at all rather than a dead one.
Antenna int `json:"antenna"`
Att int `json:"att"` // 0=off, else dB
NB bool `json:"nb"`
NR bool `json:"nr"`
NRLevel int `json:"nr_level"` // 1-15
Narrow bool `json:"narrow"` // NAR filter
// SWR is the RATIO (1.0, 1.5…), computed from the reflection coefficient —
// what an operator reads on the rig, not a percentage of meter travel.
SWR float64 `json:"swr"`
@@ -80,6 +84,7 @@ type YaesuController interface {
SetYaesuSquelch(int) error
SetYaesuAGC(string) error
SetYaesuPreamp(int) error
SetYaesuAntenna(int) error
SetYaesuAtt(int) error
SetYaesuNB(bool) error
SetYaesuNR(bool) error
@@ -239,6 +244,24 @@ func (y *Yaesu) readPanelSettings() {
if v, ok := y.askNum("PA0;", "PA0", 1); ok {
y.panel.Preamp = v
}
// ANTENNA. "AN0;" → "AN01;" — the first digit is the receiver (0 = main,
// 1 = sub on an FTDX101), the second the jack. A rig with one socket does
// not implement it and simply says nothing, which askNum reports as
// not-ok — and 0 then means "no antenna switching here".
if v, ok := y.askNum("AN0;", "AN0", 1); ok {
if y.panel.Antenna == 0 {
debugLog.Printf("yaesu: antenna select available (AN0; → %d)", v)
}
y.panel.Antenna = v
} else {
// Said once, at the first read: an operator who expects the ANT row and
// does not get it should find the reason in the log rather than wonder
// whether OpsLog forgot the feature.
if y.panel.Antenna != -1 {
debugLog.Printf("yaesu: no answer to AN0; — this rig has no antenna selection, the ANT row stays hidden")
}
y.panel.Antenna = -1
}
if v, ok := y.askNum("RA0;", "RA0", 1); ok {
y.panel.Att = yaesuAttDB(v)
}
@@ -356,6 +379,17 @@ func (y *Yaesu) SetYaesuPreamp(n int) error {
return y.setAndRefresh(fmt.Sprintf("PA0%d;", clampInt(n, 0, 2)))
}
// SetYaesuAntenna selects an antenna jack (1-3) on the main receiver.
//
// Verified against the FTDX10 command set; the FTDX101 adds a third jack and a
// sub receiver, which is the "0" in AN0 — the day someone drives a sub receiver
// from here it becomes a parameter rather than a constant. A rig without the
// command ignores it, and readPanelSettings then keeps Antenna at 0, so the
// control never appears in the first place.
func (y *Yaesu) SetYaesuAntenna(n int) error {
return y.setAndRefresh(fmt.Sprintf("AN0%d;", clampInt(n, 1, 3)))
}
func (y *Yaesu) SetYaesuAtt(db int) error {
return y.setAndRefresh(fmt.Sprintf("RA0%d;", yaesuAttCode(db)))
}
+27 -1
View File
@@ -1,6 +1,9 @@
package cat
import "testing"
import (
"fmt"
"testing"
)
func TestParseYaesuFreq(t *testing.T) {
cases := []struct {
@@ -380,3 +383,26 @@ func TestYaesuModeVFOSuffix(t *testing.T) {
t.Errorf("setting CW on main sends %q, want MD03;", cmd)
}
}
// The antenna command, as the FTDX10 reference gives it: AN + receiver + jack.
// The jack is clamped rather than trusted — a panel bug that sent AN09 would be
// answered by the rig with silence, and the operator would be left wondering
// which antenna they were on.
func TestYaesuAntennaCommand(t *testing.T) {
for _, tc := range []struct {
in int
want string
}{
{1, "AN01;"},
{2, "AN02;"},
{3, "AN03;"},
{0, "AN01;"}, // below range → the first jack
{9, "AN03;"}, // above range → the last
{-1, "AN01;"},
} {
got := fmt.Sprintf("AN0%d;", clampInt(tc.in, 1, 3))
if got != tc.want {
t.Errorf("antenna %d → %q, want %q", tc.in, got, tc.want)
}
}
}
+3
View File
@@ -36,6 +36,8 @@ const (
// 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.
@@ -130,6 +132,7 @@ type ExternalServices struct {
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
+149
View File
@@ -0,0 +1,149 @@
package extsvc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// HAMLOG.online — a cloud logbook whose confirmations feed its own award
// programme, so operators want their contacts there as they make them.
//
// # Where this protocol comes from
//
// HAMLOG publishes no API documentation. What follows is read from THEIR OWN
// client, the HAMLOG Agent (github.com/hamlogonline/Agent, Hamlog/hamlog_api.py)
// — the authoritative source short of asking them, and the same code their own
// users run:
//
// POST https://hamlog.online/api/agent/ (JSON in, JSON out)
//
// {"KEYSTATUS": {"APIKEY": k}} → {"STATUS":"OK","CALLSIGN":…,"EXPIRES":…}
// {"ADIFADD": {"APIKEY": k, "ADIFDATA": rec}} → {"STATUS":"OK"}
// {"QSOADD": {"APIKEY": k, "DATA": {…}}} → field map, keys upper-cased
// {"LOGOUT": {"APIKEY": k}}
//
// A failure answers {"ERROR": "…"} with no STATUS, so success is "STATUS is
// exactly OK" rather than "no error field" — an unknown reply shape must not
// read as an accepted QSO.
//
// ADIFADD is the verb used here: OpsLog already builds a full ADIF record for
// every other service, and sending the same bytes keeps one representation of
// a contact instead of two.
//
// The operator gets their key from https://hamlog.online/account/agent.php.
const (
hamlogAPIEndpoint = "https://hamlog.online/api/agent/"
hamlogKeyPage = "https://hamlog.online/account/agent.php"
)
// hamlogReply is the shape both success and failure share.
type hamlogReply struct {
Status string `json:"STATUS"`
Error string `json:"ERROR"`
Callsign string `json:"CALLSIGN"`
Expires any `json:"EXPIRES"` // seconds since the epoch; string or number depending on the verb
}
// hamlogPost sends one verb and decodes the reply.
func hamlogPost(ctx context.Context, client *http.Client, endpoint string, body map[string]any) (hamlogReply, error) {
buf, err := json.Marshal(body)
if err != nil {
return hamlogReply{}, fmt.Errorf("hamlog: encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf))
if err != nil {
return hamlogReply{}, fmt.Errorf("hamlog: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return hamlogReply{}, fmt.Errorf("hamlog: request failed: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
var r hamlogReply
if jerr := json.Unmarshal(raw, &r); jerr != nil {
// Not JSON at all — a proxy error page, a maintenance notice. Report what
// arrived rather than "invalid character '<'", which tells an operator
// nothing about their own setup.
msg := strings.TrimSpace(string(raw))
if len(msg) > 200 {
msg = msg[:200]
}
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return hamlogReply{}, fmt.Errorf("hamlog: unexpected reply: %s", msg)
}
return r, nil
}
// hamlogFailure turns a reply into a human-readable reason, or "" on success.
func hamlogFailure(r hamlogReply) string {
if strings.EqualFold(strings.TrimSpace(r.Status), "OK") {
return ""
}
if e := strings.TrimSpace(r.Error); e != "" {
return e
}
return "rejected"
}
// UploadHamlog pushes one ADIF record to HAMLOG.online.
func UploadHamlog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
return uploadHamlogTo(ctx, client, hamlogAPIEndpoint, cfg, adifRecord)
}
func uploadHamlogTo(ctx context.Context, client *http.Client, endpoint string, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
key := strings.TrimSpace(cfg.APIKey)
if key == "" {
return UploadResult{}, fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage)
}
rec := strings.TrimSpace(adifRecord)
if rec == "" {
return UploadResult{}, fmt.Errorf("hamlog: empty ADIF record")
}
r, err := hamlogPost(ctx, client, endpoint, map[string]any{
"ADIFADD": map[string]any{"APIKEY": key, "ADIFDATA": rec},
})
if err != nil {
return UploadResult{}, err
}
if reason := hamlogFailure(r); reason != "" {
return UploadResult{OK: false, Message: reason}, nil
}
return UploadResult{OK: true}, nil
}
// CheckHamlogKey validates an API key and reports the callsign it belongs to.
//
// Worth its own call because HAMLOG offers what no other service here does: the
// key can be checked BEFORE the first QSO, and the answer names the account. An
// operator who pasted the key of another callsign — or one that has expired —
// finds out in the settings panel rather than through a week of silent refusals.
func CheckHamlogKey(ctx context.Context, client *http.Client, key string) (callsign string, err error) {
key = strings.TrimSpace(key)
if key == "" {
return "", fmt.Errorf("hamlog: API key not set — get one at %s", hamlogKeyPage)
}
r, perr := hamlogPost(ctx, client, hamlogAPIEndpoint, map[string]any{
"KEYSTATUS": map[string]any{"APIKEY": key},
})
if perr != nil {
return "", perr
}
if reason := hamlogFailure(r); reason != "" {
return "", fmt.Errorf("hamlog: %s", reason)
}
return strings.ToUpper(strings.TrimSpace(r.Callsign)), nil
}
+87
View File
@@ -0,0 +1,87 @@
package extsvc
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// The verb, the key and the record must arrive in the shape HAMLOG's own agent
// sends — this is read from their client, not from documentation, so the test
// pins it rather than trusting a memory of it.
func TestUploadHamlogRequestShape(t *testing.T) {
var got map[string]map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method %s, want POST", r.Method)
}
if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Errorf("Content-Type %q", ct)
}
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &got)
_, _ = w.Write([]byte(`{"STATUS":"OK"}`))
}))
defer srv.Close()
res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "KEY123"}, "<call:5>F4BPO <eor>")
if err != nil {
t.Fatal(err)
}
if !res.OK {
t.Fatalf("upload not OK: %+v", res)
}
add, ok := got["ADIFADD"]
if !ok {
t.Fatalf("no ADIFADD verb in %v", got)
}
if add["APIKEY"] != "KEY123" {
t.Errorf("APIKEY = %v", add["APIKEY"])
}
if add["ADIFDATA"] != "<call:5>F4BPO <eor>" {
t.Errorf("ADIFDATA = %v", add["ADIFDATA"])
}
}
// A refusal must be reported as a refusal. Their failure shape carries ERROR
// and no STATUS, so "no error field" would have read an unknown reply as an
// accepted QSO — which is how a contact goes missing without anyone noticing.
func TestHamlogFailureIsNotSuccess(t *testing.T) {
for _, tc := range []struct {
body string
wantOK bool
wantSaid string
}{
{`{"STATUS":"OK"}`, true, ""},
{`{"ERROR":"Invalid API key"}`, false, "Invalid API key"},
{`{"STATUS":"FAILED"}`, false, "rejected"},
{`{}`, false, "rejected"}, // an empty object is not an acceptance
} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(tc.body))
}))
res, err := uploadHamlogTo(context.Background(), nil, srv.URL, ServiceConfig{APIKey: "K"}, "<eor>")
srv.Close()
if err != nil {
t.Fatalf("%s: %v", tc.body, err)
}
if res.OK != tc.wantOK {
t.Errorf("%s → OK=%v, want %v", tc.body, res.OK, tc.wantOK)
}
if !tc.wantOK && res.Message != tc.wantSaid {
t.Errorf("%s → message %q, want %q", tc.body, res.Message, tc.wantSaid)
}
}
}
// Nothing leaves without a key, and the message says where to get one.
func TestUploadHamlogNeedsAKey(t *testing.T) {
_, err := UploadHamlog(context.Background(), nil, ServiceConfig{}, "<eor>")
if err == nil || !strings.Contains(err.Error(), hamlogKeyPage) {
t.Fatalf("err = %v, want it to point at %s", err, hamlogKeyPage)
}
}
+25
View File
@@ -139,6 +139,7 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
cfg.HRDLog = cfg.HRDLog.normalised()
cfg.EQSL = cfg.EQSL.normalised()
cfg.Cloudlog = cfg.Cloudlog.normalised()
cfg.Hamlog = cfg.Hamlog.normalised()
m.cfg = cfg
// Summary of what is armed, written at startup and on every settings save.
@@ -152,6 +153,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},
} {
if s.cfg.AutoUpload {
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
@@ -227,6 +229,14 @@ func (m *Manager) OnQSOLogged(id int64) {
m.route(ServiceCloudlog, id, c)
}
}
// HAMLOG.online — one API key and nothing else to get wrong.
if h := cfg.Hamlog; h.AutoUpload {
if h.APIKey == "" {
m.logf("extsvc: hamlog auto-upload is ON but no API key is set (QSO %d not sent)", id)
} else {
m.route(ServiceHamlog, id, h)
}
}
}
// route sends a logged QSO down the configured timing path: queue it for the
@@ -277,6 +287,9 @@ func (m *Manager) onCloseServices() []Service {
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
out = append(out, ServiceCloudlog)
}
if h := cfg.Hamlog; h.AutoUpload && h.UploadMode == ModeOnClose && h.APIKey != "" {
out = append(out, ServiceHamlog)
}
return out
}
@@ -323,6 +336,8 @@ func (m *Manager) FlushOnClose() int {
uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
case ServiceCloudlog:
uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
case ServiceHamlog:
uploaded += m.flushOneByOne(svc, ids, cfg.Hamlog)
}
}
return uploaded
@@ -644,6 +659,16 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
return false, false
}
res, err = UploadCloudlog(ctx, m.deps.Client, cfg, record)
case ServiceHamlog:
// The station callsign is whatever the QSO carries: HAMLOG files the
// contact under the account the API key belongs to, and KEYSTATUS is how
// the operator checks that account is the right one.
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 = UploadHamlog(ctx, m.deps.Client, cfg, record)
default:
return false, false
}