package udp import ( "fmt" "strings" "hamlog/internal/applog" ) // This file holds the outbound emitters: OpsLog → other programs over UDP. // Formats are chosen per connection row (like the inbound parsers), so the user // can point PstRotator, a second logger, an SDR, etc. at OpsLog. // tensOfHz converts a frequency in Hz to the "tens of Hz" unit N1MM and // PstRotator use for their frequency fields (e.g. 14 025 500 Hz → 1 402 550). func tensOfHz(freqHz int64) int64 { return freqHz / 10 } // BuildPstFreq builds the datagram PstRotatorAz expects for its "DXLog.net" // tracker: {tens of Hz}. Verified by probing a // live PstRotatorAz — it reads the value as tens of Hz (14025.5 kHz → 1402550 → // displayed 3.5255 MHz for 3525.5 kHz, etc.). func BuildPstFreq(freqHz int64) []byte { return []byte(fmt.Sprintf("%d", tensOfHz(freqHz))) } // BuildN1MMRadioInfo builds an N1MM Logger+ RadioInfo XML datagram. and // are in tens of Hz. Consumed by PstRotator (as the "N1MM Logger" // tracker) and many other programs. mode is passed through (CW/USB/LSB/…). func BuildN1MMRadioInfo(station string, rxFreqHz, txFreqHz int64, mode, opCall string) []byte { if station == "" { station = "OPSLOG" } if txFreqHz == 0 { txFreqHz = rxFreqHz } var b strings.Builder b.WriteString(``) b.WriteString(``) b.WriteString(`` + xmlEsc(station) + ``) b.WriteString(`1`) b.WriteString(fmt.Sprintf(`%d`, tensOfHz(rxFreqHz))) b.WriteString(fmt.Sprintf(`%d`, tensOfHz(txFreqHz))) b.WriteString(`` + xmlEsc(mode) + ``) b.WriteString(`` + xmlEsc(opCall) + ``) b.WriteString(`True`) b.WriteString(`0`) b.WriteString(`0`) b.WriteString(``) b.WriteString(`1`) b.WriteString(`False`) b.WriteString(`1`) b.WriteString(``) return []byte(b.String()) } func xmlEsc(s string) string { r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, "'", "'") return r.Replace(s) } // RadioState is a snapshot the app pushes to EmitRadioState on freq/mode change. type RadioState struct { StationName string OpCall string RxFreqHz int64 // operating/RX frequency TxFreqHz int64 // TX frequency (may equal RX when not split) Mode string } // EmitRadioState sends the current radio frequency/mode to every enabled // outbound row whose format is frequency-based (PstRotator, N1MM RadioInfo). // Best-effort: send errors are logged, never returned to the caller. func (m *Manager) EmitRadioState(st RadioState) { for _, c := range m.Outbound(ServicePstFreq) { m.sendTo(c, BuildPstFreq(st.RxFreqHz)) } for _, c := range m.Outbound(ServiceN1MMRadio) { m.sendTo(c, BuildN1MMRadioInfo(st.StationName, st.RxFreqHz, st.TxFreqHz, st.Mode, st.OpCall)) } } // EmitLoggedADIF sends the ADIF of a just-logged QSO to every enabled outbound // "ADIF message" row (db_updated) — lets a second logger or Cloudlog gateway // pick up contacts as they're logged. func (m *Manager) EmitLoggedADIF(adif string) { if strings.TrimSpace(adif) == "" { return } rows := m.Outbound(ServiceDBUpdated) if len(rows) == 0 { // Said once per session, not per QSO. An operator who configured a second // logger and sees nothing arrive needs to know the difference between "we // sent it" and "there was nothing to send to" — and the usual cause is a // row created as an inbound ADIF listener instead of an outbound message. m.noADIFOnce.Do(func() { applog.Printf("udp: a QSO was logged but no outbound \"ADIF message\" row is enabled — " + "nothing is forwarded to another logger") }) return } for _, c := range rows { m.sendTo(c, []byte(adif)) } } // EmitLoggedQSOWSJT announces a logged contact on the WSJT-X interface, for // receivers that speak it rather than plain text — Logger32's additional UDP // sockets among them. // // BOTH messages go out, "QSO Logged" (5) then "Logged ADIF" (12), because that // is exactly what WSJT-X does for every contact. Which one a receiver takes is // its own business: MacLoggerDX reads the structured one by default and offers // the ADIF as an option, and sending only the ADIF is why a QSO reached // Logger32's socket and never reached its log. Anything built for WSJT-X // already sees both from the real thing, so neither is a surprise and nothing // logs the contact twice. func (m *Manager) EmitLoggedQSOWSJT(q LoggedQSO, adifRec string) { rows := m.Outbound(ServiceWSJTLog) if len(rows) == 0 { return } qsoPkt := BuildWSJTQSOLogged("OpsLog", q) var adifPkt []byte if strings.TrimSpace(adifRec) != "" { adifPkt = BuildWSJTLoggedADIF("OpsLog", adifRec) } for _, c := range rows { m.sendTo(c, qsoPkt) if adifPkt != nil { m.sendTo(c, adifPkt) } } } // sentPreview appends what was actually sent, for the rows where that is worth // reading. // // Custom rows only, and deliberately: their payload is a template the operator // wrote, and "sent 18 bytes" tells them nothing about whether the substitution // came out as they meant. The ADIF and WSJT-X rows carry a whole record or a // binary frame, and dumping either into the log on every QSO would bury it. // // The trigger is named too — a row that fires on the wrong moment looks exactly // like a row that does not fire. func sentPreview(c Config, payload []byte) string { if c.ServiceType != ServiceCustom { return "" } const maxShown = 200 s := string(payload) if len(s) > maxShown { s = s[:maxShown] + "…" } // %q below already renders a carriage return as \r rather than breaking the // log line in two — and it is the line ending the operator chose, so it has // to be visible. Escaping it by hand first would double every backslash. if c.Trigger != "" { return fmt.Sprintf(" on %s: %q", c.Trigger, s) } return fmt.Sprintf(": %q", s) } // sendTo resolves the row's destination (host:port) and fires one datagram. // // A successful send is logged, not just a failure. UDP has no delivery report: // when an operator says "I set up an ADIF message to Logger32 on port 2250 and // nothing arrives", the only thing that separates "OpsLog never sent it" from // "the other program did not take it" is a line saying we sent. Without one, // both look identical from here — and the first is far more common, because a // row created as an INBOUND ADIF listener rather than an OUTBOUND ADIF message // matches nothing and emits nothing, in silence. // // Rate is not a concern: these fire on a QSO being logged or a frequency // change, not per packet on a socket. func (m *Manager) sendTo(c Config, payload []byte) { host := strings.TrimSpace(c.DestinationIP) if host == "" { host = "127.0.0.1" } dst := fmt.Sprintf("%s:%d", host, c.Port) if err := SendUDP(dst, payload); err != nil { applog.Printf("udp: [%s] outbound send to %s failed: %v", c.Name, dst, err) return } applog.Printf("udp: [%s] sent %d bytes to %s (%s)%s", c.Name, len(payload), dst, c.ServiceType, sentPreview(c, payload)) }