fix(logging): say why the antenna stopped and what the ADIF port is receiving

Two problems from one operator's log, both of them the log's fault.

SteppIR. The last line about it is "status query failed, reconnecting: Port
has been closed" and then nothing — for the rest of the session. The poll loop
does retry every two seconds, but a failed open was a bare `continue`: no line,
ever. And startUltrabeam had three silent ways out — settings unreadable, the
antenna turned off, no port or host configured — so a cleared field and a lost
adapter produced exactly the same evidence, which is none. Both now say what
happened, with the port or host named. Reopen failures are reported three
times and then throttled (a port that is gone stays gone), and the recovery
says how many attempts it took.

FLDIGI. Four hundred "ADIF payload ignored" lines and nothing else legible.
The cause is in the same log: two inbound listeners on the SAME multicast
group and port, 239.255.0.1:2237 — one WSJT (MSHV), one ADIF (FLDIGI). Every
WSJT-X packet is delivered to both, and an FT8 cycle is dozens of decodes
every fifteen seconds; the bursts in the log are exactly 15 s apart. The ADIF
listener now checks for the WSJT-X magic and says so once, with what to do
about it, instead of rejecting each packet in writing. Anything else is
described a few times with its payload and then goes quiet.
This commit is contained in:
2026-08-14 16:24:06 +02:00
parent 61006d8155
commit 2cd1274975
5 changed files with 151 additions and 5 deletions
@@ -0,0 +1,40 @@
package udp
import (
"encoding/binary"
"testing"
)
// An operator's log arrived with four hundred identical "ADIF payload ignored"
// lines and nothing else legible: two inbound listeners were configured on the
// SAME multicast group and port, one WSJT and one ADIF, so every WSJT-X decode
// was delivered to both and the ADIF one rejected each in writing. An FT8 cycle
// is dozens of decodes every fifteen seconds.
//
// The listener has to recognise that traffic and say what it is — once.
func TestIgnoredADIFStaysBounded(t *testing.T) {
s := &Server{cfg: Config{Name: "FLDIGI", Port: 2237}}
// A WSJT-X packet: magic first. One line is the whole diagnosis, so the
// counter is pushed past the cap immediately.
pkt := make([]byte, 16)
binary.BigEndian.PutUint32(pkt[:4], wsjtMagic)
s.noteIgnoredADIF(pkt)
if s.badPkts <= maxBadPktDumps {
t.Errorf("badPkts = %d — WSJT traffic should silence the listener at once", s.badPkts)
}
// Anything else is described a few times before going quiet, so an ordinary
// keep-alive does not get the same one-line treatment as a real diagnosis.
s2 := &Server{cfg: Config{Name: "FLDIGI", Port: 2237}}
s2.noteIgnoredADIF([]byte("keep-alive"))
if s2.badPkts > maxBadPktDumps {
t.Errorf("one unusable payload silenced the listener (badPkts = %d)", s2.badPkts)
}
for i := 0; i < 50; i++ {
s2.noteIgnoredADIF([]byte("keep-alive"))
}
if s2.badPkts <= maxBadPktDumps {
t.Errorf("badPkts = %d — the throttle never engaged", s2.badPkts)
}
}
+40 -1
View File
@@ -2,6 +2,7 @@ package udp
import (
"context"
"encoding/binary"
"fmt"
"net"
"regexp"
@@ -268,6 +269,44 @@ func (s *Server) logBadPacket(kind string, remote *net.UDPAddr, pkt []byte, err
}
}
// noteIgnoredADIF reports a payload the ADIF listener could not use, ONCE per
// cause and then quietly.
//
// This used to log a line per datagram. An operator's log arrived with four
// hundred identical "ADIF payload ignored" lines and nothing else legible: two
// inbound listeners were configured on the SAME multicast group and port
// (239.255.0.1:2237, one WSJT and one ADIF), so every WSJT-X decode packet was
// delivered to both, and the ADIF one rejected each of them in writing. An FT8
// cycle is dozens of decodes every fifteen seconds.
//
// So the WSJT-X magic number is checked here. When it matches, the payload is
// not "chatter" — it is a specific, fixable misconfiguration, and saying which
// is the difference between a log the operator can act on and one they cannot.
func (s *Server) noteIgnoredADIF(pkt []byte) {
s.mu.Lock()
s.badPkts++
n := s.badPkts
s.mu.Unlock()
if n > maxBadPktDumps {
return
}
if len(pkt) >= 4 && binary.BigEndian.Uint32(pkt[:4]) == wsjtMagic {
applog.Printf("udp: [%s] this is WSJT-X traffic, not ADIF — port %d is shared with a WSJT source; "+
"give the ADIF forwarder (JTAlert / GridTracker) its own port, or set this listener's service to WSJT\n",
s.cfg.Name, s.cfg.Port)
// One line is the whole diagnosis: nothing is gained by counting to five.
s.mu.Lock()
s.badPkts = maxBadPktDumps + 1
s.mu.Unlock()
return
}
applog.Printf("udp: [%s] ADIF payload ignored (no <call:>/<eor>) — %s\n", s.cfg.Name, describePacket(pkt))
if n == maxBadPktDumps {
applog.Printf("udp: [%s] further unusable payloads on port %d will not be logged\n",
s.cfg.Name, s.cfg.Port)
}
}
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
switch s.cfg.ServiceType {
@@ -338,7 +377,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
text := string(pkt)
low := strings.ToLower(text)
if !strings.Contains(low, "<call:") || !strings.Contains(low, "<eor") {
applog.Printf("udp: [%s] ADIF payload ignored (no <call:>/<eor>)\n", s.cfg.Name)
s.noteIgnoredADIF(pkt)
return
}
ev.LoggedADIF = text