The ADIF datagram reached Logger32's socket and still nothing appeared in its log. WSJT-X sends TWO messages for every contact — QSO Logged (type 5), the structured one, and Logged ADIF (type 12) — and receivers differ on which they read: MacLoggerDX takes the structured one by default and offers the ADIF as an option. We were sending only the half Logger32 does not use. Both now go out on that row, in WSJT-X's own order. Nothing double-logs: any receiver built for WSJT-X already sees both from the real thing. Type 5 is read positionally, so the field order is pinned by a test against NetworkMessage.hpp — one field out of place shifts every one after it and the receiver files nonsense without complaining. QDateTime is Qt's own encoding: Julian day, milliseconds since midnight, then the time spec, which is sent as UTC (1) because a spec of 0 would have the receiver re-read the contact in its own zone. The Julian arithmetic is checked against known values; a day out there files every QSO on the wrong date.
159 lines
6.1 KiB
Go
159 lines
6.1 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("&", "&", "<", "<", ">", ">", `"`, """, "'", "'")
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|