From 55879809f2889cbb67895a09a96bddbb46a46364 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Mon, 10 Aug 2026 21:51:49 +0200 Subject: [PATCH] fix(udp): ignore N0CALL, and stop returning a grid as a callsign N0CALL is what WSJT-X transmits under when its owner never set a callsign. It has a letter, a digit and an ordinary shape, so nothing rejected it: it was spotted, coloured, counted as a new WPX prefix, and now that CQ grids are read it would have put a grid into the worked index under a callsign nobody holds. The obvious fix - adding it to looksLikeCall's reject list - was wrong, and the test caught it before it shipped. That function answers "could this token be a callsign at all", and the CQ grammar uses it to decide whether the word after CQ is a modifier (DX, NA, a zone) or the call itself. Teaching it that N0CALL is not a callsign made "CQ N0CALL JN36" skip a slot and return JN36. Shape and policy are different questions and now live in different functions. Chasing that turned up the real defect behind it: ANY unrecognised word after CQ made the parser skip a slot, and a four-character grid passes every shape test a callsign does. "CQ FOO JN36" returned JN36 as the sender - logged, spotted and coloured as a station. A grid in the callsign slot is now refused outright. --- changelog.json | 8 +++- internal/integrations/udp/config.go | 18 ++++---- internal/integrations/udp/n1mm_test.go | 24 +++++------ internal/integrations/udp/wsjt.go | 43 ++++++++++++++++--- internal/integrations/udp/wsjt_sender_test.go | 8 ++++ 5 files changed, 72 insertions(+), 29 deletions(-) diff --git a/changelog.json b/changelog.json index 55778e8..93fc53e 100644 --- a/changelog.json +++ b/changelog.json @@ -2,8 +2,12 @@ { "version": "0.24.5", "date": "", - "en": [], - "fr": [] + "en": [ + "N0CALL and NOCALL are ignored in the digital decodes. WSJT-X transmits under N0CALL when its owner never set a callsign, and it has a perfectly ordinary shape, so it was spotted, coloured, counted as a new prefix and could bring a grid into the worked index. Fixed at the same time: an unrecognised word after CQ made the parser skip a slot, and since a grid passes every shape test a callsign does, the station grid came back as its callsign." + ], + "fr": [ + "N0CALL et NOCALL sont ignorés dans les décodes digitaux. WSJT-X émet sous N0CALL quand son propriétaire n a jamais réglé son indicatif, et sa forme est parfaitement ordinaire — il était donc spotté, coloré, compté comme nouveau préfixe et pouvait faire entrer un grid dans l index des contacts. Corrigé au passage : un mot non reconnu après CQ faisait sauter un cran au parseur, et comme un grid passe tous les tests de forme d un indicatif, c est le grid de la station qui revenait comme son indicatif." + ] }, { "version": "0.24.4", diff --git a/internal/integrations/udp/config.go b/internal/integrations/udp/config.go index d856945..82a778c 100644 --- a/internal/integrations/udp/config.go +++ b/internal/integrations/udp/config.go @@ -41,16 +41,16 @@ const ( // Config is one user-defined UDP connection. type Config struct { - ID int64 `json:"id"` - Direction Direction `json:"direction"` - Name string `json:"name"` - Port int `json:"port"` + ID int64 `json:"id"` + Direction Direction `json:"direction"` + Name string `json:"name"` + Port int `json:"port"` ServiceType ServiceType `json:"service_type"` - Multicast bool `json:"multicast"` - MulticastGroup string `json:"multicast_group"` - DestinationIP string `json:"destination_ip"` // outbound only - Enabled bool `json:"enabled"` - SortOrder int `json:"sort_order"` + Multicast bool `json:"multicast"` + MulticastGroup string `json:"multicast_group"` + DestinationIP string `json:"destination_ip"` // outbound only + Enabled bool `json:"enabled"` + SortOrder int `json:"sort_order"` } // Repo is the persistence layer for UDP integration rows. diff --git a/internal/integrations/udp/n1mm_test.go b/internal/integrations/udp/n1mm_test.go index a5c3277..6b2c5cd 100644 --- a/internal/integrations/udp/n1mm_test.go +++ b/internal/integrations/udp/n1mm_test.go @@ -37,19 +37,19 @@ func TestParseN1MMContactInfo(t *testing.T) { t.Fatal("expected a loggable contact, got ok=false") } want := map[string]string{ - "VE9AA": "callsign", + "VE9AA": "callsign", "20240315": "date", - "142530": "time", - "20m": "band", - "CW": "mode", - "14.025000": "freq", - "599": "rst sent", - "599": "rst rcvd", - "FN65": "grid", - "Mike": "name", - "1": "stx serial", - "42": "srx serial", - "": "terminator", + "142530": "time", + "20m": "band", + "CW": "mode", + "14.025000": "freq", + "599": "rst sent", + "599": "rst rcvd", + "FN65": "grid", + "Mike": "name", + "1": "stx serial", + "42": "srx serial", + "": "terminator", } for sub, label := range want { if !strings.Contains(adif, sub) { diff --git a/internal/integrations/udp/wsjt.go b/internal/integrations/udp/wsjt.go index a091eb1..aab5352 100644 --- a/internal/integrations/udp/wsjt.go +++ b/internal/integrations/udp/wsjt.go @@ -284,19 +284,32 @@ func wsjtSender(message string) (call string, isCQ bool, grid string) { // 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]) { + // A word after CQ that cannot be a callsign is a modifier — UNLESS it is a + // placeholder, which does occupy the callsign slot, just uselessly. Without + // that exception "CQ NOCALL JN36" skipped a slot it should have rejected. + if len(f) > 2 && !looksLikeCall(f[1]) && !isPlaceholderCall(f[1]) { idx = 2 } - if idx < len(f) && looksLikeCall(f[idx]) { - if idx+1 < len(f) && isGridField(f[idx+1]) { - grid = f[idx+1] + if idx < len(f) { + c := f[idx] + if isPlaceholderCall(c) { + return "", true, "" + } + // 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 f[idx], true, grid } return "", true, "" } // Standard exchange: the DE (sender) call is the second token. - if len(f) >= 2 && looksLikeCall(f[1]) { + if len(f) >= 2 && looksLikeCall(f[1]) && !isPlaceholderCall(f[1]) { return f[1], false, "" } return "", false, "" @@ -317,6 +330,24 @@ func isGridField(s string) bool { s[2] >= '0' && s[2] <= '9' && s[3] >= '0' && s[3] <= '9' } +// isPlaceholderCall reports a callsign nobody actually holds. +// +// N0CALL is WSJT-X's default: someone who installed it and never set their own +// callsign transmits under it. It has a letter and a digit and a perfectly +// ordinary shape, so nothing else rejects it — it would be spotted, coloured, +// counted as a new prefix, and now that CQ grids are read it would bring a grid +// into the worked index. NOCALL (letter O) has no digit and already fails the +// shape test; it is listed so the rule can be read without deducing that. +// +// Kept OUT of looksLikeCall deliberately. That function answers "could this +// token be a callsign at all", and the CQ grammar uses it to decide whether the +// word after CQ is a modifier (DX, NA, a zone) or the callsign itself. Teaching +// it that N0CALL is not a callsign made "CQ N0CALL JN36" skip to the next token +// and return the GRID as the sender. Shape and policy are different questions. +func isPlaceholderCall(s string) bool { + return s == "N0CALL" || s == "NOCALL" +} + // 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. diff --git a/internal/integrations/udp/wsjt_sender_test.go b/internal/integrations/udp/wsjt_sender_test.go index 4d50f41..4cbcbc6 100644 --- a/internal/integrations/udp/wsjt_sender_test.go +++ b/internal/integrations/udp/wsjt_sender_test.go @@ -24,6 +24,14 @@ func TestWSJTSender(t *testing.T) { {"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 + // Placeholder callsigns: WSJT-X's default, from an operator who never set + // their own. A letter and a digit, so nothing else here catches it. + {"CQ N0CALL JN36", "", true, ""}, + {"W2XYZ N0CALL -10", "", false, ""}, + {"CQ NOCALL JN36", "", true, ""}, + // An unknown word after CQ made the parser skip a slot; a grid passes every + // shape test a callsign does, so it came back AS the callsign. + {"CQ FOO JN36", "", true, ""}, // Non-callsign / free text → no sender. {"TNX 73 GL", "", false, ""}, {"K1ABC RR73", "", false, ""}, // only one call + a token → 2nd token not a call