fix(udp): several FT8 programs no longer fight over the callsign field

Reported from a station running MSHV, WSJT-X and JTDX together: click a
call in MSHV and the entry field filled, emptied, refilled — once a
second — with the map zooming in and out to match.

Two causes, both about reading one program's statement as another's.

The clear was tracked per LISTENER. Several decoders commonly share one
multicast group, so an idle WSJT-X reporting no DX Call — which is simply
true, and which it repeats every second — was read as MSHV abandoning the
station it was calling. "The operator cleared the DX Call" is a statement
about one program, never about a socket, so it is now tracked per
program, and a clear carries the id of whoever made it.

And nothing arbitrated between them. The program that announces a station
now holds the entry field, and the others cannot touch it until it lets
go: it clears its own call, it stops sending (closed), or the QSO is
logged. That is the operator's own suggestion, and it is the right one —
between overs there is no way to tell "I have nothing" from "I am not the
one you are working" except by remembering who was.

Refusing another program's callsign is logged once per focus, not once a
second: an operator whose second decoder "stopped filling the call" needs
something to read.
This commit is contained in:
2026-09-07 21:23:11 +02:00
parent b918a8395b
commit 76022ff91c
6 changed files with 293 additions and 8 deletions
+54
View File
@@ -0,0 +1,54 @@
package udp
import "testing"
// Three decoders on one multicast group, which is the ordinary setup. MSHV is
// working a station; WSJT-X and JTDX are idle and say so once a second each.
//
// Read across the listener rather than per program, every one of those idle
// Status packets was a "the operator cleared the DX Call" — so OpsLog emptied
// the entry field, MSHV's next Status refilled it, and the entry blinked and
// the map zoomed at 1 Hz for as long as all three were running.
func TestDXClearIsPerProgram(t *testing.T) {
s := &Server{}
if s.noteDXCall("MSHV", "F5NNN") {
t.Fatal("taking up a station is not a clear")
}
// The idle ones, interleaved, as they arrive on the wire.
for i := 0; i < 3; i++ {
if s.noteDXCall("WSJT-X", "") {
t.Fatal("an idle WSJT-X was read as MSHV clearing its call")
}
if s.noteDXCall("JTDX", "") {
t.Fatal("an idle JTDX was read as MSHV clearing its call")
}
if s.noteDXCall("MSHV", "F5NNN") {
t.Fatal("MSHV repeating the same station is not a clear")
}
}
// MSHV's own clear is still an edge, and only once: the Status that follows
// is just as empty and must not re-clear a field the operator may have
// typed into since.
if !s.noteDXCall("MSHV", "") {
t.Error("MSHV clearing its own DX Call was not reported")
}
if s.noteDXCall("MSHV", "") {
t.Error("the clear repeated on the next identical Status")
}
}
// Each program's edge is its own: WSJT-X letting go says nothing about MSHV.
func TestDXClearOfOneProgramLeavesTheOthers(t *testing.T) {
s := &Server{}
s.noteDXCall("MSHV", "F5NNN")
s.noteDXCall("WSJT-X", "DL1ABC")
if !s.noteDXCall("WSJT-X", "") {
t.Error("WSJT-X clearing its own call should be reported")
}
if s.noteDXCall("MSHV", "F5NNN") {
t.Error("MSHV's unchanged call was disturbed by WSJT-X's clear")
}
}
+31 -6
View File
@@ -235,7 +235,16 @@ type Server struct {
// lastMode is the mode NAME from each program's last Status, used to resolve
// a Decode's one-character mode marker.
lastMode map[string]string
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
// lastDX is each program's last DX Call, to spot the moment it is cleared.
//
// PER PROGRAM, and that is the whole point of the map. Two or three decoders
// commonly share one listener — the multicast group on 2237 is the usual
// setup — and a single value meant WSJT-X's empty DX Call was read as MSHV
// clearing the station it was calling. One "cleared" per second, alternating
// with MSHV re-announcing the call: the entry field emptied and refilled at
// 1 Hz and the map zoomed in and out with it. "The operator cleared the DX
// call" is a statement about ONE program, never about a socket.
lastDX map[string]string
// badPkts counts datagrams this listener could not parse, so the diagnostic
// dump below stays bounded. A misconfigured port is not a one-off: the
@@ -403,6 +412,25 @@ func (s *Server) run() {
// radios on different bands. The port is included — a program keeps its socket
// for as long as it runs, which is exactly the lifetime this has to be stable
// over.
// noteDXCall records a program's current DX Call and reports whether THIS
// program has just cleared one.
//
// A decoder sends Status every second whether anything changed or not, so the
// clear is an edge — a call, then none — and it is an edge in ONE program's
// stream. Several decoders commonly share a listener, and reading the edge
// across all of them made an idle WSJT-X look like MSHV abandoning the station
// it was calling, once a second, for as long as both were running.
func (s *Server) noteDXCall(inst, dx string) (cleared bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.lastDX == nil {
s.lastDX = map[string]string{}
}
prev := s.lastDX[inst]
s.lastDX[inst] = dx
return dx == "" && prev != ""
}
func (s *Server) instanceLabel(id string, remote *net.UDPAddr) string {
if id == "" {
return ""
@@ -623,12 +651,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// operator cleared it in WSJT-X / JTDX / MSHV. Fire ONE clear (tracked per
// server) — an idle app sends empty Status every second, and we must not
// re-clear (which would fight a manual entry) on each of those.
s.mu.Lock()
prev := s.lastDX
s.lastDX = w.DXCall
s.mu.Unlock()
if w.DXCall == "" && prev != "" {
if s.noteDXCall(inst, w.DXCall) {
ev.ClearCall = true
ev.ProgramID = inst // whose clear it is — the app filters on it
}
case ServiceADIF:
// JTAlert / GridTracker forward a text ADIF record after a QSO is