177 lines
4.9 KiB
Go
177 lines
4.9 KiB
Go
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
|
|
}
|