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, DecodeCall) is non-empty depending on the message. type WSJTEvent struct { DXCall string // current "DX Call" field in the WSJT app (Status) DXGrid string // optional grid for that call (Status) Mode string // FT8 / FT4 / … FreqHz int64 // current dial freq when available (Status) LoggedADIF string // full ADIF text when message is LoggedADIF ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup // Decode (type 2): the transmitting station heard on the band. FreqHz is NOT // set here (Decode carries only the audio offset); the caller adds the last // known dial frequency (from Status) to DeltaFreqHz to get the RF frequency. IsDecode bool DecodeCall string // the sender (DE) callsign extracted from the message text DeltaFreqHz int64 // audio offset within the passband (Hz) SNR int // reported signal-to-noise (dB) IsCQ bool // the decode was a CQ call } // ParseWSJT decodes one UDP packet. Returns ok=false for messages we // don't care about (heartbeat, 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 wsjtMsgDecode: // Decode payload (v2): // bool is_new // quint32 time (ms since midnight) // qint32 snr // double delta_time (seconds) // quint32 delta_frequency (Hz, audio offset in the passband) // QUtf8 mode // QUtf8 message (the decoded text, e.g. "CQ K1ABC FN42") // bool low_confidence // bool off_air var b uint8 if err := binary.Read(r, binary.BigEndian, &b); err != nil { // is_new return WSJTEvent{}, false, err } var t32, df uint32 var snr int32 if err := binary.Read(r, binary.BigEndian, &t32); err != nil { // time return WSJTEvent{}, false, err } if err := binary.Read(r, binary.BigEndian, &snr); err != nil { return WSJTEvent{}, false, err } var dt float64 if err := binary.Read(r, binary.BigEndian, &dt); err != nil { // delta_time return WSJTEvent{}, false, err } if err := binary.Read(r, binary.BigEndian, &df); err != nil { // delta_frequency return WSJTEvent{}, false, err } mode, err := readQString(r) if err != nil { return WSJTEvent{}, false, err } msg, err := readQString(r) if err != nil { return WSJTEvent{}, false, err } call, isCQ := wsjtSender(msg) if call == "" { return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore } ev.IsDecode = true ev.DecodeCall = call ev.IsCQ = isCQ ev.DeltaFreqHz = int64(df) ev.SNR = int(snr) ev.Mode = strings.ToUpper(strings.TrimSpace(mode)) 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 } // wsjtSender extracts the transmitting (DE) callsign from a WSJT-X message and // whether it was a CQ. Grammar: // // CQ [modifier] [grid] → de_call, isCQ=true // [report|…] → de_call, isCQ=false // // Returns "" for free-text / telemetry / hashed-call messages we can't resolve. func wsjtSender(message string) (call string, isCQ bool) { f := strings.Fields(strings.ToUpper(strings.TrimSpace(message))) if len(f) == 0 { return "", false } if f[0] == "CQ" { // Skip an optional modifier after CQ (DX / a region like NA / a zone like // 020) — it never looks like a callsign (no letter+digit mix). idx := 1 if len(f) > 2 && !looksLikeCall(f[1]) { idx = 2 } if idx < len(f) && looksLikeCall(f[idx]) { return f[idx], true } return "", true } // Standard exchange: the DE (sender) call is the second token. if len(f) >= 2 && looksLikeCall(f[1]) { return f[1], false } return "", false } // looksLikeCall is a loose callsign test: 3–12 chars of A–Z/0–9//, with at least // one letter AND one digit. Rejects the fixed exchange tokens (RR73/RRR/73) that // would otherwise pass. func looksLikeCall(s string) bool { switch s { case "RR73", "RRR", "73": return false } if len(s) < 3 || len(s) > 12 { return false } hasL, hasD := false, false for i := 0; i < len(s); i++ { c := s[i] switch { case c >= 'A' && c <= 'Z': hasL = true case c >= '0' && c <= '9': hasD = true case c == '/': default: return false } } return hasL && hasD } // 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 }