fix(udp): OpsLog was re-tuning its own rig from its own broadcasts
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
<ActiveRadioNr>1</ActiveRadioNr>, i.e. "1" — and <Freq> became a tune request
for the frequency the rig was already on.
That was harmless only while <Freq> 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.
This commit is contained in:
+4
-2
@@ -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.",
|
"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.",
|
"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.",
|
"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": [
|
"fr": [
|
||||||
"Clic droit : mettre à jour le comté US des contacts sélectionnés depuis la base ULS, pour remplacer un comté renommé ou supprimé.",
|
"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é.",
|
"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.",
|
"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 »."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 — <ActiveRadioNr>1</ActiveRadioNr> — as the callsign "1", and <Freq> as
|
||||||
|
// a tune request for the frequency the rig was already on.
|
||||||
|
//
|
||||||
|
// It stayed harmless only while <Freq> 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 <Freq> — 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{"<CALLSIGN>1</CALLSIGN>", "<CALLSIGN>0</CALLSIGN>", "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("<CALLSIGN>OJ0YL<FREQ>10.112<MODE>CW"))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -423,6 +423,25 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
// Strip every angle-bracket tag, normalise whitespace, take the
|
// Strip every angle-bracket tag, normalise whitespace, take the
|
||||||
// last non-empty token. Upper-case for downstream consistency.
|
// last non-empty token. Upper-case for downstream consistency.
|
||||||
text := string(pkt)
|
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,
|
||||||
|
// <ActiveRadioNr>1</ActiveRadioNr>, as the callsign "1", and <Freq> as a
|
||||||
|
// tune request — for the frequency the rig is already on.
|
||||||
|
//
|
||||||
|
// That was harmless only for as long as <Freq> 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, "<radioinfo") {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Optional tune request: <FREQ>MHz and <MODE>str ride along with the
|
// Optional tune request: <FREQ>MHz and <MODE>str ride along with the
|
||||||
// callsign so a DXHunter spot click can drive OpsLog's CAT. Extract
|
// callsign so a DXHunter spot click can drive OpsLog's CAT. Extract
|
||||||
// (and cut) them BEFORE the generic tag-stripping below, which would
|
// (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 {
|
if len(parts) == 0 {
|
||||||
return
|
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:
|
case ServiceN1MM:
|
||||||
adifText, ok, err := ParseN1MM(pkt)
|
adifText, ok, err := ParseN1MM(pkt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -598,10 +626,36 @@ func (m *Manager) Reload(ctx context.Context) []string {
|
|||||||
m.inbound[c.ID] = srv
|
m.inbound[c.ID] = srv
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
warnSharedPorts(cfgs)
|
||||||
applog.Printf("udp: Reload done — %d server(s) running, %d error(s)", len(m.inbound), len(errs))
|
applog.Printf("udp: Reload done — %d server(s) running, %d error(s)", len(m.inbound), len(errs))
|
||||||
return 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.
|
// Outbound returns the active outbound configs matching a service type.
|
||||||
// Used by the QSO save path to push notifications to listeners.
|
// Used by the QSO save path to push notifications to listeners.
|
||||||
func (m *Manager) Outbound(service ServiceType) []Config {
|
func (m *Manager) Outbound(service ServiceType) []Config {
|
||||||
|
|||||||
Reference in New Issue
Block a user