diff --git a/internal/integrations/udp/decodemode_test.go b/internal/integrations/udp/decodemode_test.go new file mode 100644 index 0000000..fd0a2e4 --- /dev/null +++ b/internal/integrations/udp/decodemode_test.go @@ -0,0 +1,59 @@ +package udp + +import "testing" + +// A Decode does not carry the mode's NAME. It carries the one-character marker +// from the decode line — "~" for FT8, "+" for FT4 — and that character used to +// be passed on as if it were a mode. Everything downstream compared it against +// the modes in the log, matched nothing, and reported every station on an +// already-worked band as a NEW MODE. +func TestDecodeModeNameResolvesTheMarker(t *testing.T) { + for raw, want := range map[string]string{ + "~": "FT8", + "+": "FT4", + "#": "JT65", + "@": "JT9", + } { + if got := DecodeModeName(raw, "FT8"); got != want { + t.Errorf("DecodeModeName(%q) = %q, want %q", raw, got, want) + } + } +} + +// A sender that puts the real name in the field is believed as-is — several do, +// and the marker table must not get in their way. +func TestDecodeModeNameKeepsARealName(t *testing.T) { + for _, raw := range []string{"FT8", "ft4", "JS8", "Q65"} { + if got := DecodeModeName(raw, ""); got == "" || got != upper(raw) { + t.Errorf("DecodeModeName(%q) = %q, want the name itself", raw, got) + } + } +} + +// The safety net: an unknown marker falls back to the mode from the sender's +// last Status, which always carries the real name. This is what keeps a future +// or unlisted marker degrading to correct rather than to nonsense. +func TestDecodeModeNameFallsBackToStatus(t *testing.T) { + if got := DecodeModeName("%", "FT4"); got != "FT4" { + t.Errorf("unknown marker resolved to %q, want the Status mode FT4", got) + } + if got := DecodeModeName("", "FT8"); got != "FT8" { + t.Errorf("empty mode resolved to %q, want the Status mode FT8", got) + } + // Nothing known at all is empty rather than a guess: an empty mode makes the + // status resolver answer "worked", which is the safe side — a wrong mode + // would invent a new-mode flag exactly as the marker did. + if got := DecodeModeName("%", ""); got != "" { + t.Errorf("with no Status mode the result was %q, want empty", got) + } +} + +func upper(s string) string { + out := []rune(s) + for i, r := range out { + if r >= 'a' && r <= 'z' { + out[i] = r - 32 + } + } + return string(out) +} diff --git a/internal/integrations/udp/server.go b/internal/integrations/udp/server.go index 1631242..6697cc0 100644 --- a/internal/integrations/udp/server.go +++ b/internal/integrations/udp/server.go @@ -183,6 +183,9 @@ type Server struct { // lastFrom is the address each program's packets arrive from — where a Reply // has to be sent. See SendReply. lastFrom map[string]*net.UDPAddr + // lastMode is the mode NAME from each program's last Status, used to resolve + // a Decode's one-character mode marker. + lastMode map[string]string lastDX string // WSJT: last non-empty DX Call seen, to detect a clear // badPkts counts datagrams this listener could not parse, so the diagnostic @@ -436,6 +439,14 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { } s.trPeriod[w.ProgramID] = w.TRPeriod } + // The mode NAME, which only Status carries: a Decode gives the + // one-character marker instead. See DecodeModeName. + if w.Mode != "" { + if s.lastMode == nil { + s.lastMode = map[string]string{} + } + s.lastMode[w.ProgramID] = w.Mode + } s.mu.Unlock() } if !w.IsDecode && (w.TxMessage != "" || w.DECall != "") { @@ -452,6 +463,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { s.mu.Lock() dial := s.dialHz[w.ProgramID] tr := s.trPeriod[w.ProgramID] + statusMode := s.lastMode[w.ProgramID] s.mu.Unlock() if dial <= 0 { // No Status from THIS instance yet. Guessing with another @@ -464,7 +476,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { ev.DecodeFreqHz = dial + w.DeltaFreqHz ev.DecodeSNR = w.SNR ev.DecodeCQ = w.IsCQ - ev.Mode = w.Mode + ev.Mode = DecodeModeName(w.Mode, statusMode) ev.DecodeMsg = w.DecodeMsg ev.DecodeAt = decodeTime(w.DecodeMsSinceMidnight) ev.DecodeTRPeriod = tr diff --git a/internal/integrations/udp/wsjt.go b/internal/integrations/udp/wsjt.go index a953464..2362c3e 100644 --- a/internal/integrations/udp/wsjt.go +++ b/internal/integrations/udp/wsjt.go @@ -453,3 +453,50 @@ func readQString(r *bytes.Reader) (string, error) { } return string(buf), nil } + +// decodeModeChar maps the single character WSJT-X puts in a Decode's mode field +// to the mode it stands for. +// +// A Decode does NOT carry the mode's name. It carries the one-character marker +// that appears in the decode line and in ALL.TXT — "~" for FT8, "+" for FT4 — +// and that character was being passed straight through as if it were a mode. +// Everything downstream then compared "~" against the modes in the log, matched +// nothing, and called every station on an already-worked band a new MODE. +// +// The table covers what is common; anything missing falls back to the mode from +// the sender's last Status, which carries the real name — so an unlisted or +// future marker degrades to correct rather than to nonsense. +var decodeModeChar = map[string]string{ + "~": "FT8", + "+": "FT4", + "#": "JT65", + "@": "JT9", + "&": "MSK144", + ":": "Q65", + "`": "FST4", +} + +// DecodeModeName resolves a Decode's mode field to a real mode name. statusMode +// is the mode from the same program's last Status, used when the field is a +// marker we do not know, or empty. +func DecodeModeName(raw, statusMode string) string { + raw = strings.TrimSpace(raw) + if m, ok := decodeModeChar[raw]; ok { + return m + } + // A mode name is at least two alphanumeric characters ("FT8", "JS8", "Q65"). + // Anything shorter, or carrying punctuation, is a marker rather than a name. + if len(raw) >= 2 { + named := true + for _, r := range raw { + if !(r >= 'A' && r <= 'Z') && !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') { + named = false + break + } + } + if named { + return strings.ToUpper(raw) + } + } + return strings.ToUpper(strings.TrimSpace(statusMode)) +}