294 lines
8.4 KiB
Go
294 lines
8.4 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, 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] <de_call> [grid] → de_call, isCQ=true
|
||
// <to_call> <de_call> [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
|
||
}
|