diff --git a/changelog.json b/changelog.json index 35bf4ff..42aeabb 100644 --- a/changelog.json +++ b/changelog.json @@ -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: 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.", - "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": [ "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 : 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 »." ] }, { diff --git a/internal/integrations/udp/instances_test.go b/internal/integrations/udp/instances_test.go new file mode 100644 index 0000000..69139ca --- /dev/null +++ b/internal/integrations/udp/instances_test.go @@ -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) + } +} diff --git a/internal/integrations/udp/server.go b/internal/integrations/udp/server.go index 4108aed..c41bd81 100644 --- a/internal/integrations/udp/server.go +++ b/internal/integrations/udp/server.go @@ -212,6 +212,21 @@ type Server struct { // lastFrom is the address each program's packets arrive from — where a Reply // has to be sent. See SendReply. 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 // a Decode's one-character mode marker. 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 // 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 // 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. - 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 { s.lastFrom = map[string]*net.UDPAddr{} } - s.lastFrom[w.ProgramID] = remote - s.mu.Unlock() + s.lastFrom[inst] = remote } + s.mu.Unlock() // Status carries the current dial frequency; remember it so Decode audio // offsets can be turned into RF frequencies for the panadapter. if w.FreqHz > 0 && !w.IsDecode { @@ -479,7 +529,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { if s.dialHz == nil { 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 // which slot it belongs to — so it is remembered per program the // same way the dial is. @@ -487,7 +537,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { if s.trPeriod == nil { 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 // one-character marker instead. See DecodeModeName. @@ -495,7 +545,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { if s.lastMode == nil { s.lastMode = map[string]string{} } - s.lastMode[w.ProgramID] = w.Mode + s.lastMode[inst] = w.Mode } s.mu.Unlock() } @@ -507,13 +557,13 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { ev.TxMessage = w.TxMessage ev.Transmitting = w.Transmitting ev.DECall = w.DECall - ev.ProgramID = w.ProgramID + ev.ProgramID = inst } if w.IsDecode { s.mu.Lock() - dial := s.dialHz[w.ProgramID] - tr := s.trPeriod[w.ProgramID] - statusMode := s.lastMode[w.ProgramID] + dial := s.dialHz[inst] + tr := s.trPeriod[inst] + statusMode := s.lastMode[inst] s.mu.Unlock() if dial <= 0 { // 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.DecodeDial = dial ev.DecodeOffAir = w.OffAir - ev.ProgramID = w.ProgramID + ev.ProgramID = inst ev.DecodeDT = w.DeltaTime ev.DecodeAudioHz = w.DeltaFreqHz ev.DecodeMs = w.DecodeMsSinceMidnight