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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user