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: // // – a freshly logged contact // – an edited contact (same shape; we treat it as a log) // // Everything else N1MM emits on the same socket — , , // , , — 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 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 / 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("\n") return b.String(), true, nil } // writeADIFField appends a single "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 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) }