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 } for _, c := range m.Outbound(ServiceDBUpdated) { m.sendTo(c, []byte(adif)) } } // sendTo resolves the row's destination (host:port) and fires one datagram. 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) } }