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:
@@ -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,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
// Which decoder the entry field belongs to, when several are running.
|
||||
//
|
||||
// A station running WSJT-X, JTDX and MSHV at once has three programs sending
|
||||
// Status once a second each. Click a call in one of them and only that one has
|
||||
// a DX Call; the other two are idle and say so. Both statements are true, and
|
||||
// both arrive — so the entry field is filled by the program the operator is
|
||||
// working and emptied by the two that are not, once a second, and the map
|
||||
// zooms in and out with it.
|
||||
//
|
||||
// So the first program to announce a station is FOCUSED, and until it lets go
|
||||
// the others cannot touch the entry field. That is the operator's own answer:
|
||||
// "if I call on one program, keep that one's UDP for the duration of the QSO".
|
||||
//
|
||||
// Focus is released when the focused program clears its own DX Call, when it
|
||||
// stops sending altogether (it was closed), or when a QSO is logged — never on
|
||||
// a timer that could hand the field to another program mid-over.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// udpFocusIdle is how long a focused program may go silent before the focus is
|
||||
// given up.
|
||||
//
|
||||
// Generous on purpose: a decoder sends Status every second, so anything above a
|
||||
// few seconds means it has been closed or has lost its network. Thirty is long
|
||||
// enough to survive a machine that stutters and short enough that a program
|
||||
// closed mid-QSO does not lock the entry field for the rest of the evening.
|
||||
const udpFocusIdle = 30 * time.Second
|
||||
|
||||
type udpFocus struct {
|
||||
mu sync.Mutex
|
||||
inst string
|
||||
at time.Time
|
||||
// told marks that the log already carries the line explaining why another
|
||||
// program's callsign is being ignored. Once per focus, not once a second.
|
||||
told map[string]bool
|
||||
}
|
||||
|
||||
// claim records that inst is announcing a station, and reports whether inst is
|
||||
// the program the entry field currently belongs to.
|
||||
func (f *udpFocus) claim(inst string) bool {
|
||||
if inst == "" {
|
||||
return true // a sender with no id: nothing to arbitrate between
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.inst == "" || f.inst == inst || time.Since(f.at) > udpFocusIdle {
|
||||
if f.inst != inst {
|
||||
applog.Printf("udp: the entry field follows %s while it is calling", inst)
|
||||
f.told = nil
|
||||
}
|
||||
f.inst, f.at = inst, time.Now()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// holds reports whether inst may act on the entry field, without claiming it.
|
||||
// Used for the clear: a program that is not focused clearing its own DX Call
|
||||
// says nothing about the QSO in progress somewhere else.
|
||||
func (f *udpFocus) holds(inst string) bool {
|
||||
if inst == "" {
|
||||
return true
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.inst == "" || time.Since(f.at) > udpFocusIdle {
|
||||
return true
|
||||
}
|
||||
return f.inst == inst
|
||||
}
|
||||
|
||||
// release gives the field up — the focused program cleared its call, or a QSO
|
||||
// was logged and the next station may come from anywhere.
|
||||
func (f *udpFocus) release(why string) {
|
||||
f.mu.Lock()
|
||||
had := f.inst
|
||||
f.inst, f.at, f.told = "", time.Time{}, nil
|
||||
f.mu.Unlock()
|
||||
if had != "" {
|
||||
applog.Printf("udp: the entry field is free again (%s let go: %s)", had, why)
|
||||
}
|
||||
}
|
||||
|
||||
// noteIgnored logs, once per focused program, that another one's callsign was
|
||||
// not applied. Without it the behaviour is invisible: an operator whose second
|
||||
// decoder "stopped filling the call" has nothing to read.
|
||||
func (f *udpFocus) noteIgnored(inst, call string) {
|
||||
f.mu.Lock()
|
||||
if f.told == nil {
|
||||
f.told = map[string]bool{}
|
||||
}
|
||||
first := !f.told[inst]
|
||||
f.told[inst] = true
|
||||
holder := f.inst
|
||||
f.mu.Unlock()
|
||||
if first {
|
||||
applog.Printf("udp: [%s] %q not applied — %s has the entry field while it is calling",
|
||||
inst, strings.ToUpper(call), holder)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -10,7 +10,8 @@
|
||||
"The satellite panel says what a pass actually is. A countdown to AOS, or to LOS once it is up, with a bar showing where in the pass you are; rise, peak and set with their compass directions; distance, altitude and footprint; and whether it is approaching or receding, which is why the frequencies move the way they do. The frequencies keep the corrected figure large and the Doppler shift beside it.",
|
||||
"One satellite list, in one place. Settings → Lists → Satellites is gone: the birds it held by hand had nothing to do with the ones the tracker knew, and the same station kept two lists that drifted apart. The SAT_NAME box on the entry form now offers the satellites you follow — plus anything the old list still held, so nobody's typing is lost. Satellites also moved out of Hardware, where it never belonged, and sits with Operating.",
|
||||
"PstRotator can point the antenna for satellites too. It handles azimuth and elevation and already knows your controller, so if you run it, choose it in Settings → Satellites and OpsLog sends it the bearing instead of taking the cable itself — two programs on one controller is one too many. Its overlap handling stays its own: a 450° rotator is PstRotator's business, not something two programs should each decide about mid-pass.",
|
||||
"Each map keeps its own imagery. The world map and the grid-square map shared one setting, so choosing satellite imagery to look at grids repainted the main map as well, and there was no way to have terrain on one and plain streets on the other. All four — world, grid squares, FT map, satellites — now remember their own choice, and it travels with the data folder like the remembered views. A choice already made for the grid map is carried over, not reset."
|
||||
"Each map keeps its own imagery. The world map and the grid-square map shared one setting, so choosing satellite imagery to look at grids repainted the main map as well, and there was no way to have terrain on one and plain streets on the other. All four — world, grid squares, FT map, satellites — now remember their own choice, and it travels with the data folder like the remembered views. A choice already made for the grid map is carried over, not reset.",
|
||||
"Two or three FT8 programs at once no longer fight over the callsign field. Click a station in MSHV and only MSHV has a DX Call; WSJT-X and JTDX beside it are idle and say so once a second each — and OpsLog was reading those as MSHV abandoning the station, so the entry emptied and refilled at 1 Hz and the map zoomed in and out with it. A cleared DX Call is now read per program, never across the listener; and the program that announces a station keeps the entry field until it clears its own call, is closed, or the QSO is logged."
|
||||
],
|
||||
"fr": [
|
||||
"[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) suit les satellites amateurs : une carte avec l'empreinte de chacun et la trace au sol de celui qui est sélectionné, les prochains passages avec leur élévation maximale, et — pour le satellite en cours — l'azimut, l'élévation et les fréquences de descente et de montée corrigées de l'effet Doppler. Les éléments orbitaux viennent de Celestrak (avec un miroir derrière) et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. La liste de fréquences fournie couvre les satellites FM et linéaires ainsi que QO-100, dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode.",
|
||||
@@ -20,7 +21,8 @@
|
||||
"Le panneau satellite dit enfin ce qu'est un passage. Un compte à rebours jusqu'à l'AOS, ou jusqu'au LOS une fois qu'il est levé, avec une barre montrant où l'on en est ; lever, culmination et coucher avec leurs directions à la boussole ; distance, altitude et empreinte ; et s'il se rapproche ou s'éloigne, ce qui explique le sens du décalage. Les fréquences gardent la valeur corrigée en grand et le Doppler à côté.",
|
||||
"Une seule liste de satellites, à un seul endroit. Réglages → Listes → Satellites disparaît : les satellites qu'on y saisissait à la main n'avaient aucun rapport avec ceux que connaissait le suivi, et une même station entretenait deux listes qui divergeaient. Le champ SAT_NAME de la saisie propose désormais les satellites que vous suivez — plus ce que l'ancienne liste contenait encore, pour ne rien perdre. Satellites quitte aussi Matériel, où il n'avait rien à faire, pour rejoindre Opération.",
|
||||
"PstRotator peut aussi pointer l'antenne pour les satellites. Il gère azimut et élévation et connaît déjà votre contrôleur : si vous le faites tourner, choisissez-le dans Réglages → Satellites et OpsLog lui envoie le cap au lieu de prendre le câble lui-même — deux programmes sur un contrôleur, c'est un de trop. Le recouvrement reste son affaire : un rotor 450°, c'est à PstRotator d'en décider, pas à deux programmes en plein passage.",
|
||||
"Chaque carte garde son propre fond. La carte principale et celle des carrés partageaient un seul réglage : choisir la vue satellite pour regarder les carrés repeignait aussi la carte principale, et il n'y avait aucun moyen d'avoir le relief sur l'une et les rues sur l'autre. Les quatre — principale, carrés, FT map, satellites — retiennent désormais leur propre choix, qui suit le dossier de données comme les positions mémorisées. Un choix déjà fait pour la carte des carrés est repris, pas réinitialisé."
|
||||
"Chaque carte garde son propre fond. La carte principale et celle des carrés partageaient un seul réglage : choisir la vue satellite pour regarder les carrés repeignait aussi la carte principale, et il n'y avait aucun moyen d'avoir le relief sur l'une et les rues sur l'autre. Les quatre — principale, carrés, FT map, satellites — retiennent désormais leur propre choix, qui suit le dossier de données comme les positions mémorisées. Un choix déjà fait pour la carte des carrés est repris, pas réinitialisé.",
|
||||
"Deux ou trois logiciels FT8 en même temps ne se disputent plus le champ indicatif. Cliquez une station dans MSHV et lui seul a un DX Call ; WSJT-X et JTDX à côté sont au repos et le disent une fois par seconde chacun — et OpsLog y lisait MSHV abandonnant la station : le champ se vidait et se remplissait à 1 Hz, la carte zoomant au même rythme. Un DX Call effacé est désormais lu par programme, jamais à l'échelle du port ; et le logiciel qui annonce une station garde le champ jusqu'à ce qu'il efface son propre indicatif, soit fermé, ou que le QSO soit enregistré."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user