Files
OpsLog/internal/integrations/udp/outbound.go
T
rouggy d95c998081 feat(udp): speak WSJT-X to Logger32, and lighten Sahara
"sent 1153 bytes to 127.0.0.1:2250" and nothing in Logger32. Both true: the
line the operator screenshotted is titled "Setup additional WSJT/JTDX UDP
sockets", and those receivers take UDP LOGGING PACKETS — the WSJT-X v2
protocol. A bare ADIF record posted to them is dropped without a word.

So there is a new outbound service rather than a change to the existing one:
"WSJT-X logged QSO (Logger32)" sends the same ADIF wrapped in a Logged ADIF
datagram (magic 0xadbccbda, schema 2, type 12), defaulting to port 2250. The
plain-text row stays for JTAlert/GridTracker-style receivers, because the two
really are different wire formats and one row cannot be both.

The id string is "OpsLog", not "WSJT-X": a receiver uses it to tell instances
apart, and impersonating the real thing would make a second WSJT-X
indistinguishable from us.

Tested both ways — the header byte for byte, and a round trip back through our
own ParseWSJT, since that is the same decoding every receiver applies.

Sahara moves to the parchment an operator asked for: page #e6ded0, panels
#f3eee2, toolbars #ded5c4, terracotta #c4501e on the buttons and the focus
ring. Lighter and less saturated than the deep sand it was.
2026-08-14 17:51:31 +02:00

142 lines
5.5 KiB
Go

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: <PST><FREQUENCY>{tens of Hz}</FREQUENCY></PST>. 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("<PST><FREQUENCY>%d</FREQUENCY></PST>", tensOfHz(freqHz)))
}
// BuildN1MMRadioInfo builds an N1MM Logger+ RadioInfo XML datagram. <Freq> and
// <TXFreq> 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(`<?xml version="1.0" encoding="utf-8"?>`)
b.WriteString(`<RadioInfo>`)
b.WriteString(`<StationName>` + xmlEsc(station) + `</StationName>`)
b.WriteString(`<RadioNr>1</RadioNr>`)
b.WriteString(fmt.Sprintf(`<Freq>%d</Freq>`, tensOfHz(rxFreqHz)))
b.WriteString(fmt.Sprintf(`<TXFreq>%d</TXFreq>`, tensOfHz(txFreqHz)))
b.WriteString(`<Mode>` + xmlEsc(mode) + `</Mode>`)
b.WriteString(`<OpCall>` + xmlEsc(opCall) + `</OpCall>`)
b.WriteString(`<IsRunning>True</IsRunning>`)
b.WriteString(`<FocusEntry>0</FocusEntry>`)
b.WriteString(`<Antenna>0</Antenna>`)
b.WriteString(`<Rotors></Rotors>`)
b.WriteString(`<FocusRadioNr>1</FocusRadioNr>`)
b.WriteString(`<IsStereo>False</IsStereo>`)
b.WriteString(`<ActiveRadioNr>1</ActiveRadioNr>`)
b.WriteString(`</RadioInfo>`)
return []byte(b.String())
}
func xmlEsc(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")
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)
}