diff --git a/app.go b/app.go index 991b257..9abe36e 100644 --- a/app.go +++ b/app.go @@ -15172,13 +15172,32 @@ func (a *App) startUltrabeam() { go a.motorAnt.Stop() a.motorAnt = nil } + // Every way out of here used to be silent, which is how an antenna that had + // been working for three minutes went off the air with NOTHING in the log + // after the disconnection — the operator could not tell a deliberate stop + // from a lost port from a cleared setting. s, err := a.GetUltrabeamSettings() - if err != nil || !s.Enabled { + if err != nil { + applog.Printf("antenna: not started — settings unavailable: %v", err) + return + } + if !s.Enabled { + applog.Printf("antenna: not started — turned off in Settings") return } c := newMotorClient(s) if c == nil { - return // not configured (missing host/COM) + if s.Transport == "serial" { + applog.Printf("antenna: %s not started — no serial port configured", s.Type) + } else { + applog.Printf("antenna: %s not started — no host configured", s.Type) + } + return + } + if s.Transport == "serial" { + applog.Printf("antenna: %s starting on %s @ %d baud", s.Type, s.COM, s.Baud) + } else { + applog.Printf("antenna: %s starting on %s:%d", s.Type, s.Host, s.Port) } a.motorAnt = c _ = a.motorAnt.Start() diff --git a/changelog.json b/changelog.json index d7bd837..4a17f1b 100644 --- a/changelog.json +++ b/changelog.json @@ -6,13 +6,17 @@ "Send Spot: the comment now carries the award references after the mode — the ones you assigned (POTA, SOTA, IOTA…), not the DXCC, zone and prefix every reader works out from the callsign. A self-spot carries your OWN activation references instead.", "Modes: a fresh install now starts with SSB, CW, FT8, FT4, FT2, RTTY, PSK31 and FM. AM and DIGITALVOICE stay in the available list but are no longer selected by default.", "Chase new: a panel listing the stations PSK Reporter is hearing within about 300 km of you that are new against your log — on FT8, FT4, FT2, PSK31 and RTTY, and only on the bands you have selected. One badge per row, the most valuable first (entity, band, mode, slot, prefix, square), with a filter for each, a toolbar button to show it and a cross to close it. Click a row to put the callsign in the entry and tune the rig.", - "Serial ports: a port claimed by two devices in the Windows port map was listed twice in every port dropdown, and showed as “COM3COM3”. Listed once now, and in natural order — COM4 between COM3 and COM8, not after COM9." + "Serial ports: a port claimed by two devices in the Windows port map was listed twice in every port dropdown, and showed as “COM3COM3”. Listed once now, and in natural order — COM4 between COM3 and COM8, not after COM9.", + "Motorized antenna: an antenna that lost its serial port stopped answering with nothing at all in the log. Every reason it fails to start or reconnect is now written down, once.", + "UDP: an ADIF listener sharing a port with a WSJT source logged a line per decode — hundreds of them. It now says what the traffic really is, once, and how to fix it." ], "fr": [ "Envoi de spot : le commentaire porte désormais les références de diplôme après le mode — celles que vous avez attribuées (POTA, SOTA, IOTA…), pas le DXCC, la zone et le préfixe que chacun déduit de l’indicatif. Un auto-spot porte VOS références d’activation.", "Modes : une installation neuve démarre avec SSB, CW, FT8, FT4, FT2, RTTY, PSK31 et FM. AM et DIGITALVOICE restent dans la liste disponible mais ne sont plus sélectionnés par défaut.", "Chasse au nouveau : un panneau listant les stations que PSK Reporter entend à moins de 300 km de chez vous et qui sont nouvelles par rapport à votre log — en FT8, FT4, FT2, PSK31 et RTTY, et seulement sur les bandes que vous avez sélectionnées. Une seule indication par ligne, la plus précieuse d’abord (entité, bande, mode, créneau, préfixe, carré), avec un filtre pour chacune, un bouton dans la barre pour l’afficher et une croix pour le fermer. Un clic met l’indicatif en saisie et accorde la radio.", - "Ports série : un port revendiqué par deux périphériques dans la table Windows apparaissait en double dans toutes les listes, et s’affichait « COM3COM3 ». Une seule fois désormais, et dans l’ordre naturel — COM4 entre COM3 et COM8, pas après COM9." + "Ports série : un port revendiqué par deux périphériques dans la table Windows apparaissait en double dans toutes les listes, et s’affichait « COM3COM3 ». Une seule fois désormais, et dans l’ordre naturel — COM4 entre COM3 et COM8, pas après COM9.", + "Antenne motorisée : une antenne ayant perdu son port série cessait de répondre sans rien laisser dans le journal. Chaque raison d’un démarrage ou d’une reconnexion manquée est désormais écrite, une fois.", + "UDP : un écouteur ADIF partageant un port avec une source WSJT écrivait une ligne par décodage — des centaines. Il dit maintenant ce qu’est vraiment ce trafic, une fois, et comment le corriger." ] }, { diff --git a/internal/integrations/udp/adifignore_test.go b/internal/integrations/udp/adifignore_test.go new file mode 100644 index 0000000..b2f288c --- /dev/null +++ b/internal/integrations/udp/adifignore_test.go @@ -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) + } +} diff --git a/internal/integrations/udp/server.go b/internal/integrations/udp/server.go index b3cef80..bd3937b 100644 --- a/internal/integrations/udp/server.go +++ b/internal/integrations/udp/server.go @@ -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 /) — %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, "/)\n", s.cfg.Name) + s.noteIgnoredADIF(pkt) return } ev.LoggedADIF = text diff --git a/internal/steppir/steppir.go b/internal/steppir/steppir.go index 7595f49..eac50d8 100644 --- a/internal/steppir/steppir.go +++ b/internal/steppir/steppir.go @@ -88,6 +88,10 @@ type Client struct { connMu sync.Mutex conn io.ReadWriteCloser + // openFails counts consecutive failures to reopen the port, so the retry + // reports the first one and the recovery, and stays quiet in between. + // Guarded by connMu. + openFails int // ioMu serialises EVERY exchange on the shared connection — a status query // (write "?A" then read 11 bytes) and a command write must never interleave, @@ -187,6 +191,35 @@ func (c *Client) open() (io.ReadWriteCloser, error) { } } +// openFailQuiet is how many consecutive failed reopens are reported before the +// loop goes quiet about them. A port that has gone (adapter unplugged, another +// program holding it) stays gone, and one line every two seconds would be the +// entire log. +const openFailQuiet = 3 + +// noteOpenFailure logs a failure to reopen the port, throttled. Caller must NOT +// hold connMu — it is taken here. +func (c *Client) noteOpenFailure(err error) { + c.connMu.Lock() + c.openFails++ + n := c.openFails + c.connMu.Unlock() + switch { + case n <= openFailQuiet: + log.Printf("steppir: cannot open %s: %v (attempt %d)", c.target(), err, n) + case n == openFailQuiet+1: + log.Printf("steppir: still cannot open %s — retrying every 2 s, further attempts will not be logged until it comes back", c.target()) + } +} + +// target names what the client is trying to reach, for the log. +func (c *Client) target() string { + if c.tr.Mode == "serial" { + return fmt.Sprintf("%s @ %d baud", c.tr.COM, c.tr.Baud) + } + return fmt.Sprintf("%s:%d", c.tr.Host, c.tr.Port) +} + func (c *Client) pollLoop() { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() @@ -201,8 +234,19 @@ func (c *Client) pollLoop() { if err != nil { c.connMu.Unlock() c.setDisconnected() + // SAY SO. This retried every two seconds in complete silence, + // so an antenna that lost its port stopped answering and the + // log had nothing at all after the disconnection — which is + // exactly what an operator reports as "it worked for a while + // and then it didn't". Throttled, because a port that is gone + // stays gone and this would otherwise be the whole log. + c.noteOpenFailure(err) continue } + if c.openFails > 0 { + log.Printf("steppir: reconnected after %d failed attempt(s)", c.openFails) + c.openFails = 0 + } c.conn = conn } c.connMu.Unlock()