feat: upload to external services clublog qrz

This commit is contained in:
2026-05-28 22:52:50 +02:00
parent e82e30dd02
commit 5c004f5e2f
26 changed files with 1710 additions and 31 deletions
+219
View File
@@ -0,0 +1,219 @@
package udp
import (
"bytes"
"encoding/xml"
"fmt"
"strconv"
"strings"
"time"
)
// N1MM Logger+ broadcasts each logged contact as a UTF-8 XML datagram.
// We care about the two that represent a completed QSO:
//
// <contactinfo> a freshly logged contact
// <contactreplace> an edited contact (same shape; we treat it as a log)
//
// Everything else N1MM emits on the same socket — <spot>, <RadioInfo>,
// <dynamicresults>, <AppInfo>, <contactdelete> — is ignored here (spots
// are a separate feature; deletes/status aren't auto-logged).
//
// N1MM frequencies are in tens of Hz (rxfreq 1402500 == 14.025 MHz), and
// the <band> tag is the band edge in MHz as a bare number ("14", "3.5").
// We derive the ADIF band from the frequency when we have it and fall back
// to the band tag otherwise.
//
// Rather than build a qso.QSO by hand we synthesise an ADIF record and feed
// it back through the same auto-log path WSJT-X uses (LogUDPLoggedADIF):
// that gets us lookup enrichment, DXCC stamping, the operating-conditions
// stamp and dedup for free.
// n1mmContact maps the subset of <contactinfo>/<contactreplace> fields we
// promote into the logbook. Unmapped tags are dropped; anything we keep but
// the ADIF importer doesn't promote lands in the QSO's Extras.
type n1mmContact struct {
Call string `xml:"call"`
Mode string `xml:"mode"`
Band string `xml:"band"` // band edge in MHz, e.g. "14"
RxFreq string `xml:"rxfreq"` // tens of Hz; string so empty decodes cleanly
TxFreq string `xml:"txfreq"`
Timestamp string `xml:"timestamp"` // "2006-01-02 15:04:05", UTC
MyCall string `xml:"mycall"`
Operator string `xml:"operator"`
Snt string `xml:"snt"`
SntNr string `xml:"sntnr"`
Rcv string `xml:"rcv"`
RcvNr string `xml:"rcvnr"`
Grid string `xml:"gridsquare"`
Name string `xml:"name"`
QTH string `xml:"qth"`
Comment string `xml:"comment"`
Power string `xml:"power"`
ContestName string `xml:"contestname"`
}
// ParseN1MM decodes one N1MM UDP datagram. It returns ok=false (with no
// error) for datagrams that aren't a loggable contact. For a contact it
// returns a synthesised ADIF record ready for the auto-log path.
func ParseN1MM(pkt []byte) (adifText string, ok bool, err error) {
dec := xml.NewDecoder(bytes.NewReader(pkt))
for {
tok, terr := dec.Token()
if terr != nil {
// EOF before any start element, or malformed XML.
return "", false, fmt.Errorf("n1mm: no element: %w", terr)
}
se, isStart := tok.(xml.StartElement)
if !isStart {
continue
}
switch se.Name.Local {
case "contactinfo", "contactreplace":
var c n1mmContact
if derr := dec.DecodeElement(&c, &se); derr != nil {
return "", false, fmt.Errorf("n1mm: decode %s: %w", se.Name.Local, derr)
}
return c.toADIF()
default:
// spot / RadioInfo / dynamicresults / contactdelete / etc.
return "", false, nil
}
}
}
// toADIF turns a parsed contact into an ADIF record string. Returns
// ok=false if the required call/mode/date fields are missing — better to
// skip silently than to hand the auto-log path an unloggable record.
func (c n1mmContact) toADIF() (string, bool, error) {
call := strings.ToUpper(strings.TrimSpace(c.Call))
mode := normaliseN1MMMode(c.Mode)
t, terr := parseN1MMTimestamp(c.Timestamp)
if call == "" || mode == "" || terr != nil {
return "", false, nil
}
var freqHz int64
if raw := strings.TrimSpace(c.RxFreq); raw != "" {
if tens, perr := strconv.ParseInt(raw, 10, 64); perr == nil {
freqHz = tens * 10
}
}
band := bandFromHz(freqHz)
if band == "" {
band = bandFromMHzTag(c.Band)
}
if band == "" {
// No band, no log — the importer requires it.
return "", false, nil
}
var b strings.Builder
writeADIFField(&b, "call", call)
writeADIFField(&b, "qso_date", t.Format("20060102"))
writeADIFField(&b, "time_on", t.Format("150405"))
writeADIFField(&b, "band", band)
writeADIFField(&b, "mode", mode)
if freqHz > 0 {
// MHz with kHz precision, ADIF style: "14.025000".
writeADIFField(&b, "freq", strconv.FormatFloat(float64(freqHz)/1e6, 'f', 6, 64))
}
writeADIFField(&b, "rst_sent", strings.TrimSpace(c.Snt))
writeADIFField(&b, "rst_rcvd", strings.TrimSpace(c.Rcv))
writeADIFField(&b, "gridsquare", strings.TrimSpace(c.Grid))
writeADIFField(&b, "name", strings.TrimSpace(c.Name))
writeADIFField(&b, "qth", strings.TrimSpace(c.QTH))
writeADIFField(&b, "comment", strings.TrimSpace(c.Comment))
writeADIFField(&b, "tx_pwr", strings.TrimSpace(c.Power))
writeADIFField(&b, "operator", strings.ToUpper(strings.TrimSpace(c.MyCall)))
writeADIFField(&b, "contest_id", strings.TrimSpace(c.ContestName))
writeADIFField(&b, "stx", strings.TrimSpace(c.SntNr))
writeADIFField(&b, "srx", strings.TrimSpace(c.RcvNr))
b.WriteString("<eor>\n")
return b.String(), true, nil
}
// writeADIFField appends a single "<name:len>value" field, skipping empties.
func writeADIFField(b *strings.Builder, name, value string) {
if value == "" {
return
}
fmt.Fprintf(b, "<%s:%d>%s", name, len(value), value)
}
// normaliseN1MMMode maps N1MM mode strings onto ADIF modes. N1MM reports
// the sideband (USB/LSB) where ADIF wants the parent mode SSB; everything
// else passes through upper-cased.
func normaliseN1MMMode(mode string) string {
m := strings.ToUpper(strings.TrimSpace(mode))
switch m {
case "USB", "LSB":
return "SSB"
default:
return m
}
}
// parseN1MMTimestamp parses N1MM's "2006-01-02 15:04:05" UTC timestamp.
func parseN1MMTimestamp(ts string) (time.Time, error) {
return time.Parse("2006-01-02 15:04:05", strings.TrimSpace(ts))
}
// n1mmBand is one entry in the band-plan table used to derive an ADIF band
// from a dial frequency.
type n1mmBand struct {
loHz, hiHz int64
name string
}
// bandPlan covers the HF/VHF/UHF allocations a logger is likely to see.
// Ranges are generous (band edges, not country sub-bands) so an out-of-band
// dial reading still maps to the nearest band.
var bandPlan = []n1mmBand{
{1_800_000, 2_000_000, "160m"},
{3_500_000, 4_000_000, "80m"},
{5_060_000, 5_450_000, "60m"},
{7_000_000, 7_300_000, "40m"},
{10_100_000, 10_150_000, "30m"},
{14_000_000, 14_350_000, "20m"},
{18_068_000, 18_168_000, "17m"},
{21_000_000, 21_450_000, "15m"},
{24_890_000, 24_990_000, "12m"},
{28_000_000, 29_700_000, "10m"},
{50_000_000, 54_000_000, "6m"},
{70_000_000, 71_000_000, "4m"},
{144_000_000, 148_000_000, "2m"},
{222_000_000, 225_000_000, "1.25m"},
{420_000_000, 450_000_000, "70cm"},
{902_000_000, 928_000_000, "33cm"},
{1_240_000_000, 1_300_000_000, "23cm"},
}
// bandFromHz returns the ADIF band token for a dial frequency, or "" when
// the frequency is zero or outside every known allocation.
func bandFromHz(hz int64) string {
if hz <= 0 {
return ""
}
for _, b := range bandPlan {
if hz >= b.loHz && hz <= b.hiHz {
return b.name
}
}
return ""
}
// bandFromMHzTag maps N1MM's bare-MHz <band> tag ("14", "3.5") onto an ADIF
// band by treating it as a frequency at the band's low edge.
func bandFromMHzTag(tag string) string {
tag = strings.TrimSpace(tag)
if tag == "" {
return ""
}
mhz, err := strconv.ParseFloat(tag, 64)
if err != nil {
return ""
}
// Nudge just inside the low edge so e.g. "14" lands in 20m.
return bandFromHz(int64(mhz*1_000_000) + 1)
}
+103
View File
@@ -0,0 +1,103 @@
package udp
import (
"strings"
"testing"
)
// A representative N1MM+ <contactinfo> datagram (trimmed to the fields we
// read). rxfreq 1402500 tens-of-Hz == 14.025 MHz → 20m.
const sampleContactInfo = `<?xml version="1.0" encoding="utf-8"?>
<contactinfo>
<contestname>DX</contestname>
<timestamp>2024-03-15 14:25:30</timestamp>
<mycall>K1ABC</mycall>
<band>14</band>
<rxfreq>1402500</rxfreq>
<txfreq>1402500</txfreq>
<operator>K1ABC</operator>
<mode>CW</mode>
<call>VE9AA</call>
<snt>599</snt>
<sntnr>1</sntnr>
<rcv>599</rcv>
<rcvnr>42</rcvnr>
<gridsquare>FN65</gridsquare>
<name>Mike</name>
<comment>tnx</comment>
<power>100</power>
</contactinfo>`
func TestParseN1MMContactInfo(t *testing.T) {
adif, ok, err := ParseN1MM([]byte(sampleContactInfo))
if err != nil {
t.Fatalf("ParseN1MM error: %v", err)
}
if !ok {
t.Fatal("expected a loggable contact, got ok=false")
}
want := map[string]string{
"<call:5>VE9AA": "callsign",
"<qso_date:8>20240315": "date",
"<time_on:6>142530": "time",
"<band:3>20m": "band",
"<mode:2>CW": "mode",
"<freq:9>14.025000": "freq",
"<rst_sent:3>599": "rst sent",
"<rst_rcvd:3>599": "rst rcvd",
"<gridsquare:4>FN65": "grid",
"<name:4>Mike": "name",
"<stx:1>1": "stx serial",
"<srx:2>42": "srx serial",
"<eor>": "terminator",
}
for sub, label := range want {
if !strings.Contains(adif, sub) {
t.Errorf("missing %s field %q in:\n%s", label, sub, adif)
}
}
}
func TestParseN1MMSSBMapping(t *testing.T) {
pkt := `<contactinfo><call>F4XYZ</call><mode>USB</mode><band>14</band><rxfreq>1420000</rxfreq><timestamp>2024-01-01 00:00:00</timestamp></contactinfo>`
adif, ok, err := ParseN1MM([]byte(pkt))
if err != nil || !ok {
t.Fatalf("ParseN1MM ok=%v err=%v", ok, err)
}
if !strings.Contains(adif, "<mode:3>SSB") {
t.Errorf("USB should map to SSB, got:\n%s", adif)
}
}
func TestParseN1MMIgnoresNonContacts(t *testing.T) {
for _, pkt := range []string{
`<RadioInfo><app>N1MM</app><freq>1402500</freq></RadioInfo>`,
`<spot><dxcall>VE9AA</dxcall><frequency>14025</frequency></spot>`,
`<contactdelete><call>VE9AA</call></contactdelete>`,
} {
_, ok, err := ParseN1MM([]byte(pkt))
if err != nil {
t.Errorf("unexpected error for %q: %v", pkt, err)
}
if ok {
t.Errorf("expected ok=false (ignored) for %q", pkt)
}
}
}
func TestBandFromHz(t *testing.T) {
cases := map[int64]string{
14_025_000: "20m",
7_100_000: "40m",
3_650_000: "80m",
28_400_000: "10m",
144_200_000: "2m",
0: "",
15_000_000: "", // between 20m and 17m → no band
}
for hz, want := range cases {
if got := bandFromHz(hz); got != want {
t.Errorf("bandFromHz(%d) = %q, want %q", hz, got, want)
}
}
}
+24 -5
View File
@@ -45,8 +45,7 @@ type Event struct {
DXGrid string // ServiceWSJT (Status)
Mode string // ServiceWSJT (Status)
FreqHz int64 // ServiceWSJT (Status)
LoggedADIF string // ServiceWSJT (LoggedADIF) or ServiceADIF
RawText string // generic fallback (n1mm xml, etc.)
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
}
// Server is a single inbound UDP listener.
@@ -187,7 +186,17 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.FreqHz = w.FreqHz
ev.LoggedADIF = w.LoggedADIF
case ServiceADIF:
ev.LoggedADIF = string(pkt)
// JTAlert / GridTracker forward a text ADIF record after a QSO is
// logged. Guard against keep-alive / non-ADIF chatter on the socket:
// only forward payloads that actually carry a callsign field and a
// record terminator.
text := string(pkt)
low := strings.ToLower(text)
if !strings.Contains(low, "<call:") || !strings.Contains(low, "<eor") {
applog.Printf("udp: [%s] ADIF payload ignored (no <call:>/<eor>)\n", s.cfg.Name)
return
}
ev.LoggedADIF = text
case ServiceRemoteCall:
// Common payload shapes seen in the wild:
// "F4XYZ" (bare callsign)
@@ -217,12 +226,22 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
}
ev.DXCall = strings.ToUpper(parts[len(parts)-1])
case ServiceN1MM:
ev.RawText = string(pkt)
adifText, ok, err := ParseN1MM(pkt)
if err != nil {
applog.Printf("udp: [%s] N1MM parse error: %v\n", s.cfg.Name, err)
return
}
if !ok {
applog.Printf("udp: [%s] N1MM datagram ignored (not a loggable contact)\n", s.cfg.Name)
return
}
applog.Printf("udp: [%s] N1MM contact decoded (%d bytes ADIF)\n", s.cfg.Name, len(adifText))
ev.LoggedADIF = adifText
default:
return
}
// Empty events are useless; skip.
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.RawText == "" {
if ev.DXCall == "" && ev.LoggedADIF == "" {
return
}
select {