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 DecodeGrid string // 4-char grid, CQ decodes only — the exchange carries none DeltaFreqHz int64 // audio offset within the passband (Hz) SNR int // reported signal-to-noise (dB) IsCQ bool // the decode was a CQ call // DecodeMsg is the decoded text as WSJT-X printed it ("CQ K1ABC FN42", // "F4BPO K1ABC -07"). Kept whole rather than only its parsed pieces: the // exchange is what tells an operator where a station is in a QSO, and no set // of extracted fields says "R-09" the way the line itself does. DecodeMsg string // DecodeMsSinceMidnight is the decode's own timestamp, in milliseconds since // 00:00 UTC, as the sender reported it. It is what groups decodes into T/R // PERIODS — arrival time cannot, since a whole period's decodes land in one // burst and a slow link shifts the lot into the next slot. DecodeMsSinceMidnight uint32 DecodeIsNew bool // sender's "is_new": first time this line was decoded LowConfidence bool // sender is unsure of the decode OffAir bool // decoded from a file, not off the air // ---- Status extras ---- // TxMessage is what the operator is sending right now ("CQ F4BPO JN18"), // with Transmitting saying whether the carrier is actually up. Both come // from Status, so they arrive about once a second. TxMessage string Transmitting bool DECall string // the operator's own callsign, as the digital app knows it DEGrid string // and their square // TRPeriod is the transmit/receive period in seconds (15 for FT8, 7 or 8 for // FT4 depending on the sender's rounding). The authority on how long a slot // is — better than inferring it from the mode name, which says nothing about // a custom period. TRPeriod int } // maxFwdHeader bounds how far into a packet the WSJT-X magic may sit behind a // forwarder's header. The one seen in the field ("127.0.0.1:2237|") is 15 bytes; // 64 leaves room for a longer address without ever scanning a real payload. const maxFwdHeader = 64 // stripForwarderHeader removes the origin header a UDP relay prepends. // // A relay that re-broadcasts WSJT-X traffic has to say where each datagram came // from, and it does so as plain text in front of the payload: // // "127.0.0.1:2237|" + // // The magic then sits 15 bytes in, every packet fails on "bad magic", and an // operator running MSHV behind such a relay gets nothing at all. There is no // need for a separate service type: what follows the header IS a WSJT-X packet, // so the whole parser and everything downstream apply unchanged. // // Deliberately narrow. The magic must appear within maxFwdHeader bytes AND // everything before it must be printable ASCII — a truncated or corrupt packet // that happens to contain those four bytes somewhere is not resurrected into a // QSO. Anything else is returned untouched, and still fails as it did. func stripForwarderHeader(pkt []byte) []byte { if len(pkt) < 4 { return pkt } if binary.BigEndian.Uint32(pkt) == wsjtMagic { return pkt // no header — the overwhelmingly common case } limit := len(pkt) - 4 if limit > maxFwdHeader { limit = maxFwdHeader } for i := 1; i <= limit; i++ { if binary.BigEndian.Uint32(pkt[i:]) != wsjtMagic { continue } for _, b := range pkt[:i] { if b < 0x20 || b >= 0x7f { return pkt // not a text header — leave it alone } } return pkt[i:] } return pkt } // 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") } // A relay (W&P and friends) puts its own origin header in front — skip it so // the packet parses exactly as if it had arrived from WSJT-X directly. pkt = stripForwarderHeader(pkt) 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)) // report, tx_mode → skipped. for _, name := range []string{"report", "tx_mode"} { if _, err := readQString(r); err != nil { return ev, true, fmt.Errorf("read %s: %w", name, err) } } // tx_enabled, transmitting, decoding (1 byte each). The middle one is // worth keeping: it says the carrier is up, which is what turns TxMessage // from "what I would send" into "what is going out". var txEnabled, transmitting, decoding uint8 for _, p := range []*uint8{&txEnabled, &transmitting, &decoding} { if err := binary.Read(r, binary.BigEndian, p); err != nil { return ev, true, err } } ev.Transmitting = transmitting != 0 // rx_df, tx_df var i32 int32 for i := 0; i < 2; i++ { if err := binary.Read(r, binary.BigEndian, &i32); err != nil { return ev, true, err } } deCall, err := readQString(r) if err != nil { return ev, true, err } ev.DECall = strings.ToUpper(strings.TrimSpace(deCall)) deGrid, err := readQString(r) if err != nil { return ev, true, err } ev.DEGrid = strings.ToUpper(strings.TrimSpace(deGrid)) dxGrid, err := readQString(r) if err != nil { return ev, true, err } ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid)) // Everything past here was APPENDED to the schema over successive // releases, and JTDX and MSHV each stop at their own point. A short // packet is therefore normal, not an error: read as far as the sender // went and keep what we got. That is why the tail below swallows its // errors instead of reporting them — the fields already parsed are good. var b uint8 if binary.Read(r, binary.BigEndian, &b) != nil { // tx_watchdog return ev, true, nil } if _, err := readQString(r); err != nil { // sub_mode return ev, true, nil } if binary.Read(r, binary.BigEndian, &b) != nil { // fast_mode return ev, true, nil } if binary.Read(r, binary.BigEndian, &b) != nil { // special_operation_mode return ev, true, nil } var u32 uint32 if binary.Read(r, binary.BigEndian, &u32) != nil { // frequency_tolerance return ev, true, nil } if binary.Read(r, binary.BigEndian, &u32) != nil { // tr_period (seconds) return ev, true, nil } // 0xFFFFFFFF is WSJT-X's "not applicable" for the quint32 fields. if u32 > 0 && u32 < 3600 { ev.TRPeriod = int(u32) } if _, err := readQString(r); err != nil { // configuration_name return ev, true, nil } if txMsg, err := readQString(r); err == nil { ev.TxMessage = strings.TrimSpace(txMsg) } 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 } ev.DecodeIsNew = b != 0 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 } // low_confidence and off_air were appended later; absent on older senders. var lowConf, offAir uint8 _ = binary.Read(r, binary.BigEndian, &lowConf) _ = binary.Read(r, binary.BigEndian, &offAir) call, isCQ, grid := wsjtSender(msg) if call == "" { return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore } ev.IsDecode = true ev.DecodeCall = call ev.IsCQ = isCQ ev.DecodeGrid = grid ev.DeltaFreqHz = int64(df) ev.SNR = int(snr) ev.Mode = strings.ToUpper(strings.TrimSpace(mode)) ev.DecodeMsg = strings.TrimSpace(msg) ev.DecodeMsSinceMidnight = t32 ev.LowConfidence = lowConf != 0 ev.OffAir = offAir != 0 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, // whether it was a CQ, and the grid when the message carries one. Grammar: // // CQ [modifier] [grid] → de_call, isCQ=true, grid // [report|…] → de_call, isCQ=false // // Only a CQ carries a grid: the standard exchange puts a signal report in that // third slot, never a locator. // // Returns "" for free-text / telemetry / hashed-call messages we can't resolve. func wsjtSender(message string) (call string, isCQ bool, grid string) { 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) { c := f[idx] // A GRID sitting in the callsign slot means the real call was // unparseable and the skip above went one word too far. JN36 has letters // and digits and passes every shape test there is, so without this the // station's grid gets logged, spotted and coloured as its callsign. if looksLikeCall(c) && !isGridField(c) { if idx+1 < len(f) && isGridField(f[idx+1]) { grid = f[idx+1] } return c, true, grid } } 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, "" } // isGridField reports a 4-character Maidenhead field+square (JN36). // // RR73 is the reason this is not a bare pattern match: it is a sign-off, not a // locator, yet R falls inside A–R and 73 inside 00–99, so it satisfies the // Maidenhead shape exactly. WSJT-X never puts it in the slot after a CQ call, // but a station sending "CQ RR73" style free text would silently plant a // nonexistent grid in the log's grid index, and nothing downstream could tell. func isGridField(s string) bool { if len(s) != 4 || s == "RR73" { return false } return s[0] >= 'A' && s[0] <= 'R' && s[1] >= 'A' && s[1] <= 'R' && s[2] >= '0' && s[2] <= '9' && s[3] >= '0' && s[3] <= '9' } // 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 }