fix(udp): two copies of MSHV shared one dial frequency

FT8 decodes made on 14.095 were labelled 2190 m. That band is 135.7-137.8
kHz, and every affected decode's audio offset plus 136.1 kHz lands inside
it — while the ones shown with no band at all land just above 137.8. The
decodes were being stamped with another instance's dial.

WSJT-X refuses to start a second instance without --rig-name, so its ids
differ and keying on the id worked. MSHV has no such rule: both copies
call themselves 'MSHV', so the LF instance's Status overwrote the HF
one's dial, T/R period and mode.

Instances are now identified by their sending socket as well as their
name — the socket is stable for as long as the program runs, which is
exactly the lifetime this has to hold over. A second application claiming
an id already in use is shown as 'MSHV #2' and says so in the log, so the
decodes panel can still split them.

This also fixes replies: a Reply was addressed to whichever copy sent
Status last, which on a two-instance station is a coin toss.
This commit is contained in:
2026-08-24 21:38:30 +02:00
parent bd4f1b2325
commit d8064cc426
3 changed files with 118 additions and 14 deletions
+4 -2
View File
@@ -6,13 +6,15 @@
"Elecraft console: MOX unkeys again, and the power and SWR bars move while transmitting. The poll stops while the carrier is up — the rig refuses most questions then — and the panel was left believing the radio was still receiving, so pressing MOX a second time sent another transmit command instead of stopping.", "Elecraft console: MOX unkeys again, and the power and SWR bars move while transmitting. The poll stops while the carrier is up — the rig refuses most questions then — and the panel was left believing the radio was still receiving, so pressing MOX a second time sent another transmit command instead of stopping.",
"Elecraft console: RIT and XIT can be moved — ±10 and ±100 Hz buttons with the offset shown beside them. A lit RIT button says the feature is on and nothing about where it has put the receiver.", "Elecraft console: RIT and XIT can be moved — ±10 and ±100 Hz buttons with the offset shown beside them. A lit RIT button says the feature is on and nothing about where it has put the receiver.",
"Elecraft console: a 4.0 kHz filter button, the K3 maximum and the one FT8 wants.", "Elecraft console: a 4.0 kHz filter button, the K3 maximum and the one FT8 wants.",
"FT decodes: a station that is new on both the band and the mode now shows both badges. That status had no badge of its own, so those rows passed the BAND and MODE filters and then displayed no reason for being in the list — only their grid or prefix badge, which looked like the filter leaking." "FT decodes: a station that is new on both the band and the mode now shows both badges. That status had no badge of its own, so those rows passed the BAND and MODE filters and then displayed no reason for being in the list — only their grid or prefix badge, which looked like the filter leaking.",
"Two copies of MSHV are now told apart. MSHV calls every instance 'MSHV', and the dial frequency was filed under that name alone — so a second copy on another band overwrote the first, and decodes made on 14.095 came out labelled 2190 m. Instances are identified by their sending socket as well as their name, and a second one is shown as 'MSHV #2'."
], ],
"fr": [ "fr": [
"Console Elecraft : MOX repasse bien en réception, et les barres de puissance et de ROS bougent pendant l'émission. L'interrogation s'arrête quand la porteuse est levée — la radio refuse alors la plupart des questions — et le panneau croyait donc la radio toujours en réception : un second appui sur MOX envoyait une nouvelle commande d'émission au lieu d'arrêter.", "Console Elecraft : MOX repasse bien en réception, et les barres de puissance et de ROS bougent pendant l'émission. L'interrogation s'arrête quand la porteuse est levée — la radio refuse alors la plupart des questions — et le panneau croyait donc la radio toujours en réception : un second appui sur MOX envoyait une nouvelle commande d'émission au lieu d'arrêter.",
"Console Elecraft : le RIT et le XIT se règlent — boutons ±10 et ±100 Hz avec le décalage affiché à côté. Un bouton RIT allumé dit que la fonction est active et rien sur l'endroit où elle a mis le récepteur.", "Console Elecraft : le RIT et le XIT se règlent — boutons ±10 et ±100 Hz avec le décalage affiché à côté. Un bouton RIT allumé dit que la fonction est active et rien sur l'endroit où elle a mis le récepteur.",
"Console Elecraft : un bouton de filtre à 4,0 kHz, le maximum du K3 et celui qu'il faut pour le FT8.", "Console Elecraft : un bouton de filtre à 4,0 kHz, le maximum du K3 et celui qu'il faut pour le FT8.",
"Décodes FT : une station nouvelle à la fois sur la bande et dans le mode affiche maintenant les deux badges. Ce statut n'avait pas de badge à lui : ces lignes passaient les filtres BANDE et MODE puis n'affichaient aucune raison d'être là — seulement leur badge de locator ou de préfixe, ce qui donnait l'impression d'un filtre qui fuyait." "Décodes FT : une station nouvelle à la fois sur la bande et dans le mode affiche maintenant les deux badges. Ce statut n'avait pas de badge à lui : ces lignes passaient les filtres BANDE et MODE puis n'affichaient aucune raison d'être là — seulement leur badge de locator ou de préfixe, ce qui donnait l'impression d'un filtre qui fuyait.",
"Deux copies de MSHV sont maintenant distinguées. MSHV nomme chaque instance « MSHV », et la fréquence d'affichage était rangée sous ce seul nom : une seconde copie sur une autre bande écrasait la première, et des décodes faits sur 14.095 ressortaient étiquetés 2190 m. Les instances sont désormais identifiées par leur socket autant que par leur nom, et la seconde s'affiche « MSHV #2 »."
] ]
}, },
{ {
@@ -0,0 +1,52 @@
package udp
import (
"net"
"testing"
)
// Two copies of MSHV call themselves "MSHV". WSJT-X refuses to start a second
// instance without --rig-name, so its ids differ; MSHV has no such rule, and
// everything a decode needs — the dial frequency, the T/R period, the mode —
// used to be filed under the id alone.
//
// The consequence was reported from a real station: the second copy, on LF,
// overwrote the dial of the first, and FT8 decodes made on 14.095 came out
// labelled 2190 m — which is 136 kHz plus an audio offset, to the hertz.
func TestSameProgramIDFromTwoAddressesAreTwoInstances(t *testing.T) {
s := &Server{}
a := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2237}
b := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2238}
first := s.instanceLabel("MSHV", a)
second := s.instanceLabel("MSHV", b)
if first == second {
t.Fatalf("two applications on different sockets share the label %q", first)
}
if first != "MSHV" {
t.Fatalf("the first instance should keep the plain id, got %q", first)
}
// Stable: the same socket must keep its name for as long as it runs, or the
// decodes panel would grow a new column every time a packet arrived.
if again := s.instanceLabel("MSHV", a); again != first {
t.Fatalf("label changed for the same address: %q then %q", first, again)
}
if again := s.instanceLabel("MSHV", b); again != second {
t.Fatalf("label changed for the same address: %q then %q", second, again)
}
}
// Distinct ids stay distinct without decoration — a station running WSJT-X and
// MSHV should not see either renamed.
func TestDifferentProgramIDsAreLeftAlone(t *testing.T) {
s := &Server{}
a := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2237}
b := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2238}
if got := s.instanceLabel("WSJT-X", a); got != "WSJT-X" {
t.Fatalf("WSJT-X was renamed to %q", got)
}
if got := s.instanceLabel("MSHV", b); got != "MSHV" {
t.Fatalf("MSHV was renamed to %q", got)
}
}
+61 -11
View File
@@ -212,6 +212,21 @@ type Server struct {
// lastFrom is the address each program's packets arrive from — where a Reply // lastFrom is the address each program's packets arrive from — where a Reply
// has to be sent. See SendReply. // has to be sent. See SendReply.
lastFrom map[string]*net.UDPAddr lastFrom map[string]*net.UDPAddr
// instLabel names each running application, keyed by id AND sending address.
//
// WSJT-X requires --rig-name for a second instance, so its ids differ. MSHV
// does not: two copies both call themselves "MSHV", and everything below was
// keyed on that name alone — so the second copy's Status overwrote the
// first's dial frequency, and decodes from the 20 m instance were placed at
// the LF instance's frequency. Reported as FT8 decodes on 14.095 labelled
// 2190 m, which is exactly 136 kHz plus an audio offset.
//
// The label is what the rest of OpsLog sees: the plain id for the first
// instance, then "MSHV #2", "MSHV #3"… so the decodes panel can still tell
// them apart on screen.
instLabel map[string]string
// instSeq counts how many distinct instances have claimed each id.
instSeq map[string]int
// lastMode is the mode NAME from each program's last Status, used to resolve // lastMode is the mode NAME from each program's last Status, used to resolve
// a Decode's one-character mode marker. // a Decode's one-character mode marker.
lastMode map[string]string lastMode map[string]string
@@ -374,6 +389,40 @@ func (s *Server) run() {
} }
} }
// instanceLabel returns the name for one running application, allocating it on
// first sight. Caller holds s.mu.
//
// The address is part of the identity because the id is not enough: two copies
// of MSHV send the same id from different sockets, and the two are different
// 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.
func (s *Server) instanceLabel(id string, remote *net.UDPAddr) string {
if id == "" {
return ""
}
if remote == nil {
return id
}
key := id + "|" + remote.String()
if s.instLabel == nil {
s.instLabel = map[string]string{}
s.instSeq = map[string]int{}
}
if lbl, ok := s.instLabel[key]; ok {
return lbl
}
s.instSeq[id]++
lbl := id
if n := s.instSeq[id]; n > 1 {
lbl = fmt.Sprintf("%s #%d", id, n)
applog.Printf("udp: [%s] a second application calls itself %q (from %s) — it will be shown as %q",
s.cfg.Name, id, remote, lbl)
}
s.instLabel[key] = lbl
return lbl
}
// logBadPacket reports a datagram this listener could not parse, with enough of // logBadPacket reports a datagram this listener could not parse, with enough of
// it to identify the sender — then falls silent. // it to identify the sender — then falls silent.
// //
@@ -464,14 +513,15 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// to it. Per PROGRAM, not per listener: two receivers share one multicast // to it. Per PROGRAM, not per listener: two receivers share one multicast
// group, and a reply must reach the one that heard the station — and it // group, and a reply must reach the one that heard the station — and it
// must go to the sender's own address, never to the group. // must go to the sender's own address, never to the group.
if w.ProgramID != "" && remote != nil {
s.mu.Lock() s.mu.Lock()
inst := s.instanceLabel(w.ProgramID, remote)
if inst != "" && remote != nil {
if s.lastFrom == nil { if s.lastFrom == nil {
s.lastFrom = map[string]*net.UDPAddr{} s.lastFrom = map[string]*net.UDPAddr{}
} }
s.lastFrom[w.ProgramID] = remote s.lastFrom[inst] = remote
s.mu.Unlock()
} }
s.mu.Unlock()
// Status carries the current dial frequency; remember it so Decode audio // Status carries the current dial frequency; remember it so Decode audio
// offsets can be turned into RF frequencies for the panadapter. // offsets can be turned into RF frequencies for the panadapter.
if w.FreqHz > 0 && !w.IsDecode { if w.FreqHz > 0 && !w.IsDecode {
@@ -479,7 +529,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
if s.dialHz == nil { if s.dialHz == nil {
s.dialHz = map[string]int64{} s.dialHz = map[string]int64{}
} }
s.dialHz[w.ProgramID] = w.FreqHz s.dialHz[inst] = w.FreqHz
// The T/R period travels with Status, and a decode has to be told // The T/R period travels with Status, and a decode has to be told
// which slot it belongs to — so it is remembered per program the // which slot it belongs to — so it is remembered per program the
// same way the dial is. // same way the dial is.
@@ -487,7 +537,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
if s.trPeriod == nil { if s.trPeriod == nil {
s.trPeriod = map[string]int{} s.trPeriod = map[string]int{}
} }
s.trPeriod[w.ProgramID] = w.TRPeriod s.trPeriod[inst] = w.TRPeriod
} }
// The mode NAME, which only Status carries: a Decode gives the // The mode NAME, which only Status carries: a Decode gives the
// one-character marker instead. See DecodeModeName. // one-character marker instead. See DecodeModeName.
@@ -495,7 +545,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
if s.lastMode == nil { if s.lastMode == nil {
s.lastMode = map[string]string{} s.lastMode = map[string]string{}
} }
s.lastMode[w.ProgramID] = w.Mode s.lastMode[inst] = w.Mode
} }
s.mu.Unlock() s.mu.Unlock()
} }
@@ -507,13 +557,13 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.TxMessage = w.TxMessage ev.TxMessage = w.TxMessage
ev.Transmitting = w.Transmitting ev.Transmitting = w.Transmitting
ev.DECall = w.DECall ev.DECall = w.DECall
ev.ProgramID = w.ProgramID ev.ProgramID = inst
} }
if w.IsDecode { if w.IsDecode {
s.mu.Lock() s.mu.Lock()
dial := s.dialHz[w.ProgramID] dial := s.dialHz[inst]
tr := s.trPeriod[w.ProgramID] tr := s.trPeriod[inst]
statusMode := s.lastMode[w.ProgramID] statusMode := s.lastMode[inst]
s.mu.Unlock() s.mu.Unlock()
if dial <= 0 { if dial <= 0 {
// No Status from THIS instance yet. Guessing with another // No Status from THIS instance yet. Guessing with another
@@ -534,7 +584,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.DecodeTRPeriod = tr ev.DecodeTRPeriod = tr
ev.DecodeDial = dial ev.DecodeDial = dial
ev.DecodeOffAir = w.OffAir ev.DecodeOffAir = w.OffAir
ev.ProgramID = w.ProgramID ev.ProgramID = inst
ev.DecodeDT = w.DeltaTime ev.DecodeDT = w.DeltaTime
ev.DecodeAudioHz = w.DeltaFreqHz ev.DecodeAudioHz = w.DeltaFreqHz
ev.DecodeMs = w.DecodeMsSinceMidnight ev.DecodeMs = w.DecodeMsSinceMidnight