This commit is contained in:
2026-05-28 21:32:46 +02:00
parent e8cac569e3
commit e82e30dd02
29 changed files with 2485 additions and 97 deletions
+176
View File
@@ -0,0 +1,176 @@
package udp
import (
"bytes"
"encoding/binary"
"fmt"
"strings"
)
// WSJT-X / JTDX / MSHV UDP protocol (WSJT-X v2 schema).
//
// Wire format:
// uint32 magic (0xadbccbda)
// uint32 schema (2 or 3)
// uint32 type (message id)
// QString id (the program's "id" — typically "WSJT-X")
// ... type-specific payload ...
//
// QString = int32 length followed by `length` UTF-8 bytes, or -1 for nil.
// QUtf8 in newer versions; same wire format for the common case.
//
// We only care about two messages here:
// Status (type 1) → exposes the current DX call so HamLog can pre-fill
// LoggedADIF (type 12) → carries the ADIF of the just-logged QSO
// Everything else (heartbeat, decodes, clears, status of other VFOs) is
// ignored.
const (
wsjtMagic = 0xadbccbda
wsjtMsgHeartbeat = 0
wsjtMsgStatus = 1
wsjtMsgDecode = 2
wsjtMsgClear = 3
wsjtMsgQSOLogged = 5
wsjtMsgLoggedADIF = 12
)
// WSJTEvent is the parsed, typed result of decoding a single packet.
// One of (DXCall, LoggedADIF) is non-empty depending on the message.
type WSJTEvent struct {
DXCall string // current "DX Call" field in the WSJT app
DXGrid string // optional grid for that call
Mode string // FT8 / FT4 / …
FreqHz int64 // current dial freq when available
LoggedADIF string // full ADIF text when message is LoggedADIF
ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup
}
// ParseWSJT decodes one UDP packet. Returns ok=false for messages we
// don't care about (heartbeat, decode lines, clears, etc.).
func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
if len(pkt) < 12 {
return WSJTEvent{}, false, fmt.Errorf("packet too short")
}
r := bytes.NewReader(pkt)
var magic, schema, mtype uint32
if err := binary.Read(r, binary.BigEndian, &magic); err != nil {
return WSJTEvent{}, false, err
}
if magic != wsjtMagic {
return WSJTEvent{}, false, fmt.Errorf("bad magic %#x", magic)
}
if err := binary.Read(r, binary.BigEndian, &schema); err != nil {
return WSJTEvent{}, false, err
}
_ = schema
if err := binary.Read(r, binary.BigEndian, &mtype); err != nil {
return WSJTEvent{}, false, err
}
id, err := readQString(r)
if err != nil {
return WSJTEvent{}, false, fmt.Errorf("read id: %w", err)
}
ev := WSJTEvent{ProgramID: id}
switch mtype {
case wsjtMsgStatus:
// Status payload order (v2):
// quint64 dial_frequency
// QUtf8 mode
// QUtf8 dx_call
// QUtf8 report
// QUtf8 tx_mode
// bool tx_enabled
// bool transmitting
// bool decoding
// qint32 rx_df
// qint32 tx_df
// QUtf8 de_call
// QUtf8 de_grid
// QUtf8 dx_grid
// ... (more fields appended in later schemas, we stop reading
// after dx_grid which is all we need)
var dialHz uint64
if err := binary.Read(r, binary.BigEndian, &dialHz); err != nil {
return WSJTEvent{}, false, err
}
ev.FreqHz = int64(dialHz)
mode, err := readQString(r)
if err != nil {
return WSJTEvent{}, false, err
}
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
dxCall, err := readQString(r)
if err != nil {
return WSJTEvent{}, false, err
}
ev.DXCall = strings.ToUpper(strings.TrimSpace(dxCall))
// Skip report, tx_mode (QUtf8), tx_enabled (bool), transmitting,
// decoding, rx_df (qint32), tx_df (qint32), de_call (QUtf8),
// de_grid (QUtf8) → then dx_grid.
for _, name := range []string{"report", "tx_mode"} {
if _, err := readQString(r); err != nil {
return ev, true, fmt.Errorf("read %s: %w", name, err)
}
}
// 3 booleans (each 1 byte)
for i := 0; i < 3; i++ {
var b uint8
if err := binary.Read(r, binary.BigEndian, &b); err != nil {
return ev, true, err
}
}
// 2 int32
var i32 int32
for i := 0; i < 2; i++ {
if err := binary.Read(r, binary.BigEndian, &i32); err != nil {
return ev, true, err
}
}
// de_call, de_grid, dx_grid
if _, err := readQString(r); err != nil {
return ev, true, err
}
if _, err := readQString(r); err != nil {
return ev, true, err
}
dxGrid, err := readQString(r)
if err != nil {
return ev, true, err
}
ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))
return ev, true, nil
case wsjtMsgLoggedADIF:
// Payload: a single QString containing the ADIF record.
adif, err := readQString(r)
if err != nil {
return WSJTEvent{}, false, err
}
ev.LoggedADIF = adif
return ev, true, nil
}
return WSJTEvent{}, false, nil
}
// readQString reads a Qt QString as written by QDataStream: an int32 byte
// length (or -1 for null) followed by the UTF-8 bytes.
func readQString(r *bytes.Reader) (string, error) {
var n int32
if err := binary.Read(r, binary.BigEndian, &n); err != nil {
return "", err
}
if n <= 0 {
return "", nil
}
if int(n) > r.Len() {
return "", fmt.Errorf("short string: want %d have %d", n, r.Len())
}
buf := make([]byte, n)
if _, err := r.Read(buf); err != nil {
return "", err
}
return string(buf), nil
}