Every FTx decode WSJT-X, JTDX or MSHV puts on the wire, grouped by T/R
period. Optional and closable, from Tools -> FT decodes; its open state is
remembered, because an operator running digital modes leaves it open for
the session rather than consulting and closing it.
The period is the point, and what separates this from the cluster list.
FT8 is a sequence of fifteen-second slots and a band is read by watching
them go by: who called CQ this slot, who answered, what I was sending while
they did. A flat list sorted by time loses exactly that, so the list is
grouped one section per period, newest first, with the operator's own
transmission shown inside the slot it went out in.
Three fields had to be carried up from the wire to make it possible:
- the decode's OWN timestamp, which the parser read and threw away. It is
what assigns a slot: a period's decodes arrive in one burst a second or
two after it closes, so arrival time piles a whole period into the next
one. Rebuilt to UTC from milliseconds-since-midnight, with the
day-boundary case handled - a decode stamped 23:59:58 arriving at
00:00:01 would otherwise be dated a day ahead and sit at the top of the
list for the rest of the session.
- the decoded line itself. The exchange is what says where a station is in
a QSO, and no set of extracted fields reads like "R-09" does.
- tx_message and transmitting from Status, which nothing parsed before.
Recorded once per message rather than on every Status, which repeats it
about once a second for the whole over.
Also picked up on the way: is_new, low_confidence, off_air, the operator's
own call and grid, and the T/R period itself - better authority on slot
length than the mode name, which says nothing about a custom period. The
Status tail is read defensively: those fields were appended over successive
schema versions and JTDX and MSHV each stop at their own point, so a short
packet is normal and keeps whatever parsed.
Status flags come from ClusterSpotStatuses, the resolver the cluster list
and band map already use, filling the same cache. One verdict per call:
"new band" in this panel and plain worked in the cluster two seconds later
would be worse than no flag at all. Clicking a call goes through the same
handler as a cluster spot, so answering a station is one gesture whether it
came off telnet or off the receiver.
Filters: CQ only, new-anything only, band, mode, continent, an SNR floor
and a free search. The band, mode and continent choices are built from what
is actually on the feed - offering 160 m to a station whose receivers are
all on 6 m is noise.
Decodes are held in the frontend and pruned to a rolling half hour: they
are a live view, not data, nothing outside the panel reads them, and a
night of FT8 on 20 m would otherwise grow a list no filter can rescue.
Arrivals are staged on a 300 ms timer so a period landing as fifty packets
costs one status lookup and one render.
451 lines
15 KiB
Go
451 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
|
||
// 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
|
||
}
|
||
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
|
||
}
|