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
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"testing"
"time"
)
// The reported failure, in order: MSHV is called on, WSJT-X and JTDX sit idle
// beside it, and every one of their Status packets used to empty the entry
// field that MSHV had just filled — once a second, with the map zooming in and
// out to match.
func TestUdpFocusKeepsTheFieldWithTheCallingProgram(t *testing.T) {
var f udpFocus
if !f.claim("MSHV") {
t.Fatal("the first program to announce a station must take the field")
}
// The other two, announcing stations of their own, are refused.
if f.claim("WSJT-X") {
t.Error("WSJT-X took the field while MSHV was calling")
}
if f.claim("JTDX") {
t.Error("JTDX took the field while MSHV was calling")
}
// And their clears do not empty it — this is the half that caused the flicker.
if f.holds("WSJT-X") {
t.Error("an idle WSJT-X was allowed to clear MSHV's callsign")
}
if !f.holds("MSHV") {
t.Error("MSHV lost the right to clear its own callsign")
}
// MSHV moving to the next station keeps the field.
if !f.claim("MSHV") {
t.Error("the focused program must keep the field across stations")
}
}
// Letting go, three ways.
func TestUdpFocusRelease(t *testing.T) {
var f udpFocus
// The focused program clears its own call.
f.claim("MSHV")
f.release("DX Call cleared")
if !f.claim("WSJT-X") {
t.Error("after a release the next program should be able to take the field")
}
// A QSO is logged.
f.release("QSO logged")
if !f.claim("JTDX") {
t.Error("logging a QSO must free the field for whichever program hears the next station")
}
// The focused program is closed and stops sending. Its hold lapses rather
// than locking the entry field for the rest of the evening.
f.mu.Lock()
f.at = time.Now().Add(-udpFocusIdle - time.Second)
f.mu.Unlock()
if !f.claim("MSHV") {
t.Error("a silent program must not hold the field for ever")
}
}
// A sender with no program id — an ADIF relay, a remote "set call" — is not
// something to arbitrate between, and must never be locked out.
func TestUdpFocusIgnoresUnnamedSenders(t *testing.T) {
var f udpFocus
f.claim("MSHV")
if !f.claim("") {
t.Error("an unnamed sender was refused the entry field")
}
if !f.holds("") {
t.Error("an unnamed sender was refused a clear")
}
}