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:
2026-08-10 16:52:36 +02:00
parent 0a8c1ac45f
commit 9b2115be8f
6 changed files with 204 additions and 41 deletions
+11 -9
View File
@@ -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