From 9f8e3c73d9754b2a3d75c223cc368aca71602746 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Sun, 16 Aug 2026 12:25:41 +0200 Subject: [PATCH] fix(udp): OpsLog was re-tuning its own rig from its own broadcasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as JTDX "Fake It" causing CAT disconnections. Fake It is not the cause — it is what made an existing loop fire, and a regression of mine from this morning is what closed that loop. The station has an inbound remote-call row and an outbound N1MM RadioInfo row on the same port, 2241, so every RadioInfo datagram OpsLog sends arrives straight back on the loopback. The remote-call parser strips XML tags and takes the last token as the callsign: for a RadioInfo that is 1, i.e. "1" — and became a tune request for the frequency the rig was already on. That was harmless only while was misread. It is in tens of Hz, the parser assumed MHz, every echoed tune failed "out of the 11-digit CAT range", and the loop died on the error. Teaching it the unit (331db58) closed the loop. Fake It shifts the dial for each over rather than using split, so every transmission changed the state twice — and each change published a RadioInfo, which came back as a set, which changed the state again. The log shows the dial oscillating 24915000/24915500 three times in 200 ms, then "timeout answering IF;" and the shared link down. Every over, all morning. Two guards, because one was not enough to be sure: a RadioInfo payload is never a remote-call request, and a callsign has a letter in it — so the next program to broadcast its state on that port cannot drive the rig either. Reload also names an inbound and an outbound row sharing a port, which is the arrangement that permitted this and which no settings panel shows. --- changelog.json | 6 +- internal/integrations/udp/selfecho_test.go | 74 ++++++++++++++++++++++ internal/integrations/udp/server.go | 56 +++++++++++++++- 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 internal/integrations/udp/selfecho_test.go 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 {