feat: upload to external services clublog qrz
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user