Files
OpsLog/internal/integrations/udp/n1mm.go
T

220 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}