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)) } } // EmitLoggedADIFWSJT sends the same record wrapped as a WSJT-X "Logged ADIF" // datagram, for receivers that speak that interface rather than plain text — // Logger32's additional UDP sockets among them. func (m *Manager) EmitLoggedADIFWSJT(adifRec string) { if strings.TrimSpace(adifRec) == "" { return } for _, c := range m.Outbound(ServiceWSJTLog) { m.sendTo(c, BuildWSJTLoggedADIF("OpsLog", adifRec)) } } // 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)", c.Name, len(payload), dst, c.ServiceType) }