Files
OpsLog/internal/integrations/udp/wsjt.go
T
2026-07-10 17:16:32 +02:00

294 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 312 chars of AZ/09//, 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
}