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.
150 lines
5.3 KiB
Go
150 lines
5.3 KiB
Go
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
|
|
}
|