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 }