feat(cluster): grids from the UDP decodes, plus spotter continent and LoTW
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.
This commit is contained in:
@@ -47,18 +47,19 @@ func reusingListenConfig() net.ListenConfig {
|
||||
// Event is what a Server emits to its consumer for every parsed packet.
|
||||
// At most one of the fields is populated per event.
|
||||
type Event struct {
|
||||
ConfigID int64
|
||||
Service ServiceType
|
||||
Source string // remote addr that sent the packet, for diagnostics
|
||||
ConfigID int64
|
||||
Service ServiceType
|
||||
Source string // remote addr that sent the packet, for diagnostics
|
||||
|
||||
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
|
||||
DXGrid string // ServiceWSJT (Status)
|
||||
Mode string // ServiceWSJT (Status/Decode)
|
||||
FreqHz int64 // ServiceWSJT (Status)
|
||||
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
|
||||
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
|
||||
DXGrid string // ServiceWSJT (Status)
|
||||
Mode string // ServiceWSJT (Status/Decode)
|
||||
FreqHz int64 // ServiceWSJT (Status)
|
||||
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
|
||||
|
||||
// A WSJT-X Decode (heard station) to render on the panadapter.
|
||||
DecodeCall string // transmitting (DE) callsign
|
||||
DecodeGrid string // 4-char grid, CQ decodes only
|
||||
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
||||
DecodeSNR int // reported SNR (dB)
|
||||
DecodeCQ bool // the decode was a CQ
|
||||
@@ -93,7 +94,7 @@ type Server struct {
|
||||
// 50.400 panadapter. WSJT-X requires --rig-name for a second instance, so the
|
||||
// id is distinct whenever there is more than one.
|
||||
dialHz map[string]int64
|
||||
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
||||
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
||||
|
||||
// badPkts counts datagrams this listener could not parse, so the diagnostic
|
||||
// dump below stays bounded. A misconfigured port is not a one-off: the
|
||||
@@ -300,6 +301,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
return
|
||||
}
|
||||
ev.DecodeCall = w.DecodeCall
|
||||
ev.DecodeGrid = w.DecodeGrid
|
||||
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
||||
ev.DecodeSNR = w.SNR
|
||||
ev.DecodeCQ = w.IsCQ
|
||||
|
||||
@@ -39,18 +39,19 @@ const (
|
||||
// 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
|
||||
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
|
||||
@@ -239,13 +240,14 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||
if err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
call, isCQ := wsjtSender(msg)
|
||||
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))
|
||||
@@ -263,17 +265,20 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||
return WSJTEvent{}, false, nil
|
||||
}
|
||||
|
||||
// wsjtSender extracts the transmitting (DE) callsign from a WSJT-X message and
|
||||
// whether it was a CQ. Grammar:
|
||||
// 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
|
||||
// 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) {
|
||||
func wsjtSender(message string) (call string, isCQ bool, grid string) {
|
||||
f := strings.Fields(strings.ToUpper(strings.TrimSpace(message)))
|
||||
if len(f) == 0 {
|
||||
return "", false
|
||||
return "", false, ""
|
||||
}
|
||||
if f[0] == "CQ" {
|
||||
// Skip an optional modifier after CQ (DX / a region like NA / a zone like
|
||||
@@ -283,15 +288,33 @@ func wsjtSender(message string) (call string, isCQ bool) {
|
||||
idx = 2
|
||||
}
|
||||
if idx < len(f) && looksLikeCall(f[idx]) {
|
||||
return f[idx], true
|
||||
if idx+1 < len(f) && isGridField(f[idx+1]) {
|
||||
grid = f[idx+1]
|
||||
}
|
||||
return f[idx], true, grid
|
||||
}
|
||||
return "", 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 f[1], false, ""
|
||||
}
|
||||
return "", 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
|
||||
|
||||
@@ -7,26 +7,34 @@ func TestWSJTSender(t *testing.T) {
|
||||
msg string
|
||||
wantCall string
|
||||
wantCQ bool
|
||||
wantGrid string
|
||||
}{
|
||||
{"CQ K1ABC FN42", "K1ABC", true},
|
||||
{"CQ DX W2XYZ EM12", "W2XYZ", true}, // modifier "DX" skipped
|
||||
{"CQ NA VE3ABC FN03", "VE3ABC", true}, // region modifier skipped
|
||||
{"CQ 020 JA1XYZ PM95", "JA1XYZ", true},// zone modifier skipped
|
||||
{"W2XYZ K1ABC -10", "K1ABC", false}, // exchange → sender is 2nd call
|
||||
{"W2XYZ K1ABC R-10", "K1ABC", false},
|
||||
{"W2XYZ K1ABC RR73", "K1ABC", false},
|
||||
{"F4BPO K1ABC/P 73", "K1ABC/P", false},// portable call kept
|
||||
{"CQ F/DL1ABC JO31", "F/DL1ABC", true},// compound prefix
|
||||
{"CQ K1ABC FN42", "K1ABC", true, "FN42"},
|
||||
{"CQ DX W2XYZ EM12", "W2XYZ", true, "EM12"}, // modifier "DX" skipped
|
||||
{"CQ NA VE3ABC FN03", "VE3ABC", true, "FN03"}, // region modifier skipped
|
||||
{"CQ 020 JA1XYZ PM95", "JA1XYZ", true, "PM95"}, // zone modifier skipped
|
||||
{"W2XYZ K1ABC -10", "K1ABC", false, ""}, // exchange → sender is 2nd call
|
||||
{"W2XYZ K1ABC R-10", "K1ABC", false, ""},
|
||||
{"W2XYZ K1ABC RR73", "K1ABC", false, ""},
|
||||
{"F4BPO K1ABC/P 73", "K1ABC/P", false, ""}, // portable call kept
|
||||
{"CQ F/DL1ABC JO31", "F/DL1ABC", true, "JO31"}, // compound prefix, grid still valid
|
||||
{"CQ K1ABC", "K1ABC", true, ""}, // CQ without a grid
|
||||
{"CQ K1ABC RR73", "K1ABC", true, ""}, // RR73 is a sign-off, NOT grid RR73
|
||||
{"CQ K1ABC FN4", "K1ABC", true, ""}, // 3 chars is not a field+square
|
||||
{"CQ K1ABC FN42AB", "K1ABC", true, ""}, // 6-char: WSJT-X never sends it here
|
||||
{"CQ K1ABC 73", "K1ABC", true, ""}, // bare sign-off
|
||||
{"CQ K1ABC SS42", "K1ABC", true, ""}, // S is past R — no such field
|
||||
// Non-callsign / free text → no sender.
|
||||
{"TNX 73 GL", "", false},
|
||||
{"K1ABC RR73", "", false}, // only one call + a token → 2nd token not a call
|
||||
{"", "", false},
|
||||
{"CQ CQ CQ", "", true}, // CQ but no resolvable call
|
||||
{"TNX 73 GL", "", false, ""},
|
||||
{"K1ABC RR73", "", false, ""}, // only one call + a token → 2nd token not a call
|
||||
{"", "", false, ""},
|
||||
{"CQ CQ CQ", "", true, ""}, // CQ but no resolvable call
|
||||
}
|
||||
for _, c := range cases {
|
||||
call, cq := wsjtSender(c.msg)
|
||||
if call != c.wantCall || cq != c.wantCQ {
|
||||
t.Errorf("wsjtSender(%q) = (%q,%v), want (%q,%v)", c.msg, call, cq, c.wantCall, c.wantCQ)
|
||||
call, cq, grid := wsjtSender(c.msg)
|
||||
if call != c.wantCall || cq != c.wantCQ || grid != c.wantGrid {
|
||||
t.Errorf("wsjtSender(%q) = (%q,%v,%q), want (%q,%v,%q)",
|
||||
c.msg, call, cq, grid, c.wantCall, c.wantCQ, c.wantGrid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user