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
+20
View File
@@ -885,6 +885,10 @@ type App struct {
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
// udpFocus arbitrates the entry field between several decoders running at
// once — see app_udp_focus.go.
udpFocus udpFocus
// Satellites. The elements (where the birds are) and the frequency plan
// (what to do with the radio) are held apart because they come from
// different places and change for different reasons — a feed every few
@@ -3132,6 +3136,9 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
}
if err == nil {
q.ID = id
// The contact is over, so no decoder holds the entry field any more: the
// next station may come from whichever one hears it first.
a.udpFocus.release("QSO logged")
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory)
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
// Snapshot the QSO recording SYNCHRONOUSLY, BEFORE announcing the log: the
@@ -14616,6 +14623,13 @@ func (a *App) consumeUDPEvents() {
"adif": ev.LoggedADIF,
})
case ev.ClearCall:
// Only from the program the entry field belongs to. An idle decoder
// alongside the one being worked clears its own DX Call for reasons
// of its own, and that must not empty a field somebody else filled.
if !a.udpFocus.holds(ev.ProgramID) {
break
}
a.udpFocus.release("DX Call cleared")
applog.Printf("udp: emit udp:clear_call (DX Call cleared in the digital app)\n")
wruntime.EventsEmit(a.ctx, "udp:clear_call", map[string]any{
"service": string(ev.Service),
@@ -14640,6 +14654,12 @@ func (a *App) consumeUDPEvents() {
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
}
case ev.DXCall != "":
// With two or three decoders running, the one announcing a station
// takes the entry field and keeps it until it lets go. See udpFocus.
if !a.udpFocus.claim(ev.ProgramID) {
a.udpFocus.noteIgnored(ev.ProgramID, ev.DXCall)
break
}
applog.Printf("udp: emit udp:dx_call %q (mode=%s freq=%d)\n", ev.DXCall, ev.Mode, ev.FreqHz)
wruntime.EventsEmit(a.ctx, "udp:dx_call", map[string]any{
"call": ev.DXCall,