Clicking a decode now ANSWERS it. It sends WSJT-X/MSHV a Reply message (type 4), which is the same thing as double-clicking the line in their own Band Activity window: the application looks the decode up, sets its transmit frequency to the caller's and starts the exchange. It deliberately does not tune the radio, which is what it did before and why nothing happened. On FT8 the whole band sits inside one passband, so moving the dial changes nothing about who gets answered - the decision belongs to the decoding application, and the Reply is the only way to hand it over. Tuning would also just fight it for the VFO. The entry is still filled so the QSO can be logged here. The reply is routed by PROGRAM ID, not by listener: two receivers can share one multicast group, and answering a station heard on the 6 m instance by talking to the 20 m one would start a call on the wrong band. It goes to the address that instance's packets actually arrive from - a multicast listener must answer the sender, never the group. WSJT-X matches the reply against its own decode list, so the payload replays the decode field for field: time, snr, delta time, audio offset, mode and message text. Two columns added, DT and Freq - the audio offset inside the passband, not the RF frequency, which is the same for every station in the list and says nothing. Past about two seconds DT takes a warning tint: that station is drifting out of the window. The transmit strip. "You cannot see what you are sending, or who you are calling" - two separate faults. The message was only ever threaded into its period, and in FT8 you transmit in the slots you are NOT receiving in, so its period had no decodes and the whole line was dropped; a transmit slot now creates its period. And the state is a strip of its own at the top, because it is the one thing on the screen that is about the operator rather than the band. It is fed by every Status rather than only by one carrying transmit text, so it can still name the station being called on MSHV and older JTDX builds, which stop before tx_message in the Status payload. "New only" became per-category badges, in the colours and the vocabulary of the Chase New panel. None lit shows the whole band - this is a decode log first, and a panel that opened by hiding most of the traffic would be lying about what is on the air.
456 lines
15 KiB
Go
456 lines
15 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
|
||
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
|
||
// DeltaTime is how far into the slot the transmission started, in seconds —
|
||
// WSJT-X's "DT" column. Read and discarded before; kept now because it is
|
||
// shown, and because a Reply has to replay the decode field for field.
|
||
DeltaTime float64
|
||
// 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 original, untouched WSJT-X packet>
|
||
//
|
||
// 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
|
||
}
|
||
ev.DeltaTime = dt
|
||
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] <de_call> [grid] → de_call, isCQ=true, grid
|
||
// <to_call> <de_call> [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
|
||
}
|