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.
This commit is contained in:
2026-08-14 17:51:31 +02:00
parent c5c9d355ec
commit d95c998081
10 changed files with 165 additions and 32 deletions
+4
View File
@@ -37,6 +37,10 @@ const (
ServiceDBUpdated ServiceType = "db_updated" // ADIF of each locally-logged QSO (on save)
ServicePstFreq ServiceType = "pstrotator_freq" // <PST><FREQUENCY> radio freq (on freq change)
ServiceN1MMRadio ServiceType = "n1mm_radioinfo" // N1MM RadioInfo XML: freq+mode (on freq/mode change)
// ServiceWSJTLog wraps the same ADIF in a WSJT-X "Logged ADIF" datagram.
// Logger32 and others listen on the WSJT-X interface, not for plain text, and
// discard a bare ADIF record without a word — so the two cannot be one row.
ServiceWSJTLog ServiceType = "wsjt_log"
)
// Config is one user-defined UDP connection.
+12
View File
@@ -103,6 +103,18 @@ func (m *Manager) EmitLoggedADIF(adif string) {
}
}
// 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:
+50
View File
@@ -0,0 +1,50 @@
package udp
import (
"bytes"
"encoding/binary"
)
// Sending WSJT-X UDP messages, as opposed to parsing them (wsjt.go).
//
// This exists for Logger32, and for anything else that listens on the WSJT-X
// interface rather than for plain text. Logger32's "additional WSJT/JTDX UDP
// sockets" receivers — ports 2250, 2251, 2252 — are documented as receiving
// "UDP logging packets": they speak the WSJT-X v2 protocol, and a raw ADIF
// record posted to them is discarded without a word.
//
// That is a real distinction and not a detail: an operator can watch OpsLog
// report "sent 1153 bytes to 127.0.0.1:2250" and see nothing whatsoever appear
// in Logger32, because both statements are true.
// buildQString encodes a Qt QString/QByteArray as QDataStream writes it: a
// big-endian int32 length followed by the UTF-8 bytes. A negative length means
// null, which is not what we ever want here — an empty string is length 0.
func buildQString(s string) []byte {
out := make([]byte, 4, 4+len(s))
binary.BigEndian.PutUint32(out, uint32(len(s)))
return append(out, s...)
}
// BuildWSJTLoggedADIF frames a WSJT-X "Logged ADIF" datagram (message type 12).
//
// uint32 magic 0xadbccbda
// uint32 schema 2
// uint32 type 12
// QString id the sending program's name
// QString adif the ADIF record
//
// id matters more than it looks: a receiver uses it to tell instances apart, and
// some show it in their log. "OpsLog" is honest — pretending to be WSJT-X would
// make a second instance of the real thing indistinguishable from us.
func BuildWSJTLoggedADIF(id, adif string) []byte {
var b bytes.Buffer
var hdr [12]byte
binary.BigEndian.PutUint32(hdr[0:4], wsjtMagic)
binary.BigEndian.PutUint32(hdr[4:8], 2) // schema 2 — what WSJT-X 2.x speaks
binary.BigEndian.PutUint32(hdr[8:12], wsjtMsgLoggedADIF)
b.Write(hdr[:])
b.Write(buildQString(id))
b.Write(buildQString(adif))
return b.Bytes()
}
@@ -0,0 +1,49 @@
package udp
import (
"bytes"
"testing"
)
// What we send has to be what we can read back: Logger32 and the rest decode
// this with the same rules our own parser uses, so a round trip through
// ParseWSJT is the honest check.
//
// The case behind it: OpsLog reported "sent 1153 bytes to 127.0.0.1:2250" and
// Logger32 showed nothing. Both were true — its UDP sockets speak the WSJT-X
// protocol and discard a bare ADIF record without a word.
func TestWSJTLoggedADIFRoundTrips(t *testing.T) {
const rec = "<CALL:5>VK9XX<BAND:3>20m<MODE:3>FT8<QSO_DATE:8>20260814<TIME_ON:6>174433<EOR>"
pkt := BuildWSJTLoggedADIF("OpsLog", rec)
ev, ok, err := ParseWSJT(pkt)
if err != nil {
t.Fatalf("ParseWSJT: %v", err)
}
if !ok {
t.Fatal("our own parser did not recognise the datagram we build")
}
if ev.LoggedADIF != rec {
t.Errorf("ADIF = %q, want %q", ev.LoggedADIF, rec)
}
if ev.ProgramID != "OpsLog" {
t.Errorf("id = %q, want OpsLog — a receiver uses it to tell instances apart", ev.ProgramID)
}
}
// The header is fixed and other programs match on it byte for byte.
func TestWSJTLoggedADIFHeader(t *testing.T) {
pkt := BuildWSJTLoggedADIF("OpsLog", "<EOR>")
want := []byte{
0xad, 0xbc, 0xcb, 0xda, // magic
0x00, 0x00, 0x00, 0x02, // schema 2
0x00, 0x00, 0x00, 0x0c, // type 12 — Logged ADIF
0x00, 0x00, 0x00, 0x06, // len("OpsLog")
'O', 'p', 's', 'L', 'o', 'g',
0x00, 0x00, 0x00, 0x05, // len("<EOR>")
'<', 'E', 'O', 'R', '>',
}
if !bytes.Equal(pkt, want) {
t.Errorf("packet = % X\nwant % X", pkt, want)
}
}