Backend groundwork; the columns and filters that consume it come next. GRIDS. A CQ is the one WSJT-X message that carries a locator, and wsjtSender was throwing that token away. It is now returned, validated as a real field+square, and remembered per callsign. This is the ONLY grid source available: a DX-cluster line carries the spotter's grid at best and never the DX's, and a per-callsign QRZ lookup under an RBN firehose is not a trade worth making. So grids are known for the stations this receiver decoded - which is exactly the FT8/FT4 watering hole an operator is looking at while grid chasing. RR73 is why the grid is validated rather than pattern-matched. R is inside A-R and 73 inside 00-99, so a sign-off satisfies the Maidenhead shape exactly and would have planted a grid that does not exist into the index, silently. NEW GRID keys on "GRID|MODE" with the mode put through the same normMode as everything else, so the "group digital modes" option decides whether a grid worked on FT8 is still new on FT4 - one rule, no branch. Grids are truncated to four characters: a log holds a mix of JN36 and JN36QU, and without that the same square is new forever, once per subsquare. On cost, which was the condition: one more DISTINCT scan when the status snapshot is rebuilt, then map lookups per spot. The same shape as the county and POTA sets it sits beside, and the snapshot exists precisely so a spot batch never touches the logbook. Spotter continent and the LoTW flag come from tables already in memory - the DXCC prefix table and ARRL's user list - so they cost a lookup each. The spotter continent answers a different question from the DX's: whether anyone near you is hearing this at all.
366 lines
11 KiB
Go
366 lines
11 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
|
||
}
|
||
|
||
// 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))
|
||
// 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, 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))
|
||
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) && looksLikeCall(f[idx]) {
|
||
if idx+1 < len(f) && isGridField(f[idx+1]) {
|
||
grid = f[idx+1]
|
||
}
|
||
return f[idx], 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
|
||
}
|