diff --git a/changelog.json b/changelog.json index d91de9c..85dd1ad 100644 --- a/changelog.json +++ b/changelog.json @@ -6,13 +6,15 @@ "Right-click: update the US county of the selected contacts from the ULS database, replacing a county since renamed or abolished.", "A QSO logged from WSJT-X, MSHV or a net now appears in Recent QSOs at once, instead of waiting for a delayed auto-upload to send it.", "New installs: the default QSL and recording e-mails end with a credit line and a link to OpsLog. Part of the template, so delete it if unwanted.", - "Relay automatic control and band-change messages now follow the Band selector too, so a station without CAT switches its antenna when you change band." + "Relay automatic control and band-change messages now follow the Band selector too, so a station without CAT switches its antenna when you change band.", + "Fixed OpsLog re-tuning its own rig from its own radio broadcasts, which dropped the CAT link on every JTDX or WSJT-X “Fake It” transmission." ], "fr": [ "Clic droit : mettre à jour le comté US des contacts sélectionnés depuis la base ULS, pour remplacer un comté renommé ou supprimé.", "Un QSO logué depuis WSJT-X, MSHV ou un net apparaît aussitôt dans les QSO récents, sans attendre l’envoi d’un upload automatique différé.", "Nouvelles installations : les mails QSL et enregistrement par défaut finissent par une ligne de crédit et un lien vers OpsLog. Dans le modèle, supprimable.", - "Le contrôle automatique des relais et les messages de changement de bande suivent aussi le champ Band : une station sans CAT commute enfin son antenne." + "Le contrôle automatique des relais et les messages de changement de bande suivent aussi le champ Band : une station sans CAT commute enfin son antenne.", + "Corrigé : OpsLog réaccordait sa propre radio depuis ses propres diffusions, ce qui coupait le lien CAT à chaque émission JTDX ou WSJT-X en « Fake It »." ] }, { diff --git a/internal/integrations/udp/selfecho_test.go b/internal/integrations/udp/selfecho_test.go new file mode 100644 index 0000000..483d08f --- /dev/null +++ b/internal/integrations/udp/selfecho_test.go @@ -0,0 +1,74 @@ +package udp + +import ( + "net" + "testing" + "time" +) + +// feed runs one datagram through a remote-call listener and returns the event +// it produced, or nil. +func feed(t *testing.T, pkt []byte) *Event { + t.Helper() + out := make(chan Event, 4) + s := &Server{ + cfg: Config{ID: 1, Name: "DX HUNTER", ServiceType: ServiceRemoteCall, Port: 2241}, + out: out, + } + s.handle(pkt, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2241}) + select { + case ev := <-out: + return &ev + case <-time.After(200 * time.Millisecond): + return nil + } +} + +// A RadioInfo datagram must never be read as a remote-call request. +// +// This is the loop from a reported session. An inbound remote-call row and an +// outbound RadioInfo row shared port 2241, so every datagram OpsLog sent came +// straight back on the loopback. The tag-stripping heuristic read its last +// token — 1 — as the callsign "1", and as +// a tune request for the frequency the rig was already on. +// +// It stayed harmless only while was misread as MHz: the tune failed "out +// of the CAT range" and the loop died there. Reading the unit correctly closed +// it, and with JTDX "Fake It" — which shifts the dial for every over — each +// transmission set off a burst of sets echoing between OpsLog and itself until +// the rig stopped answering IF; and the shared CAT link dropped. +func TestRadioInfoIsNotARemoteCall(t *testing.T) { + pkt := BuildN1MMRadioInfo("F5PHW", 24_915_000, 24_915_000, "FT8", "F5PHW") + + // The trap this closes: the payload really does parse as a plausible dial + // frequency, so nothing downstream would have questioned it. + m := remoteFreqRe.FindStringSubmatch(string(pkt)) + if m == nil { + t.Fatal("the RadioInfo no longer carries a — this test is checking nothing") + } + if hz := remoteTuneHz(m[1]); hz != 24_915_000 { + t.Fatalf("remoteTuneHz(%q) = %d — the dial frequency back is what made the loop live", m[1], hz) + } + + if ev := feed(t, pkt); ev != nil { + t.Errorf("a RadioInfo produced a remote-call event %+v — the rig would be re-tuned to where it already is", *ev) + } +} + +// A callsign has a letter in it. Refusing by shape as well as by name means the +// next program to broadcast its state on this port cannot drive the rig either. +func TestRemoteCallNeedsALetter(t *testing.T) { + for _, body := range []string{"1", "0", "12345"} { + if ev := feed(t, []byte(body)); ev != nil { + t.Errorf("%q was accepted as a callsign: %+v", body, *ev) + } + } + // A genuine spot click still gets through, tune request and all. + ev := feed(t, []byte("OJ0YL10.112CW")) + if ev == nil { + t.Fatal("a genuine spot click produced no event") + } + if ev.DXCall != "OJ0YL" || ev.TuneFreqHz != 10_112_000 || ev.TuneMode != "CW" { + t.Errorf("spot click decoded as %+v, want OJ0YL / 10112000 Hz / CW", *ev) + } +} diff --git a/internal/integrations/udp/server.go b/internal/integrations/udp/server.go index a25365b..cdd38ca 100644 --- a/internal/integrations/udp/server.go +++ b/internal/integrations/udp/server.go @@ -423,6 +423,25 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { // Strip every angle-bracket tag, normalise whitespace, take the // last non-empty token. Upper-case for downstream consistency. text := string(pkt) + // NEVER act on an N1MM RadioInfo datagram. + // + // It is not a spot click, it is a radio TELLING the world where it is — + // and on a station where an inbound remote-call row and an outbound + // RadioInfo row share a port, the one OpsLog just sent arrives straight + // back on the loopback. The tag-stripping below then reads its last token, + // 1, as the callsign "1", and as a + // tune request — for the frequency the rig is already on. + // + // That was harmless only for as long as was misread: in tens of Hz + // it looks like a wild number, every tune failed "out of the CAT range", + // and the loop died there. Reading the unit correctly closed it. With + // WSJT-X/JTDX "Fake It", which shifts the dial for each over, every + // transmission then produced a burst of sets echoing between OpsLog and + // itself until the rig stopped answering IF; and the shared CAT link + // dropped. Reported as Fake It causing CAT disconnections. + if low := strings.ToLower(text); strings.Contains(low, "MHz and str ride along with the // callsign so a DXHunter spot click can drive OpsLog's CAT. Extract // (and cut) them BEFORE the generic tag-stripping below, which would @@ -454,7 +473,16 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { if len(parts) == 0 { return } - ev.DXCall = strings.ToUpper(parts[len(parts)-1]) + call := strings.ToUpper(parts[len(parts)-1]) + // A callsign has a letter in it. Without this, any status XML that ends + // in a number is read as a station — the RadioInfo above was exactly + // that, and refusing it by shape as well as by name means the next + // program to broadcast its state on this port cannot drive the rig + // either. + if !strings.ContainsAny(call, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") { + return + } + ev.DXCall = call case ServiceN1MM: adifText, ok, err := ParseN1MM(pkt) if err != nil { @@ -598,10 +626,36 @@ func (m *Manager) Reload(ctx context.Context) []string { m.inbound[c.ID] = srv m.mu.Unlock() } + warnSharedPorts(cfgs) applog.Printf("udp: Reload done — %d server(s) running, %d error(s)", len(m.inbound), len(errs)) return errs } +// warnSharedPorts names an inbound and an outbound row sitting on the same +// port, because that is a loop: what OpsLog sends there, OpsLog receives. +// +// It is how a station ended up re-tuning its own rig from its own RadioInfo +// broadcasts. The parser refuses that particular payload now, but the +// arrangement stays wrong for anything else that lands on the port, and it is +// invisible in a settings panel that shows one row at a time. +func warnSharedPorts(cfgs []Config) { + in := map[int]string{} + for _, c := range cfgs { + if c.Enabled && c.Direction != Outbound { + in[c.Port] = c.Name + } + } + for _, c := range cfgs { + if !c.Enabled || c.Direction != Outbound { + continue + } + if name, ok := in[c.Port]; ok { + applog.Printf("udp: %q sends on port %d and %q listens on it — OpsLog will receive its own messages there; give one of the two another port", + c.Name, c.Port, name) + } + } +} + // Outbound returns the active outbound configs matching a service type. // Used by the QSO save path to push notifications to listeners. func (m *Manager) Outbound(service ServiceType) []Config {