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 {