chore: release v0.26.0

This commit is contained in:
2026-08-20 17:53:55 +02:00
parent 47992b5f03
commit 1e507225dd
44 changed files with 3197 additions and 240 deletions
+13
View File
@@ -37,6 +37,19 @@ const (
ServiceDBUpdated ServiceType = "db_updated" // ADIF of each locally-logged QSO (on save)
ServicePstFreq ServiceType = "pstrotator_freq" // <PST><FREQUENCY> radio freq (on freq change)
ServiceN1MMRadio ServiceType = "n1mm_radioinfo" // N1MM RadioInfo XML: freq+mode (on freq/mode change)
// ServiceWSJTRelay re-sends every datagram an inbound WSJT listener receives,
// byte for byte, to somewhere else.
//
// WSJT-X, JTDX and MSHV send to ONE address. Anything else that wants the
// same stream — JTAlert, GridTracker, a second logger — has to be fed by a
// relay, and this is it, so OpsLog stops being the reason you cannot run the
// two together.
//
// VERBATIM, with no origin header prepended. The relays in the field add one
// ("127.0.0.1:2237|"), which is precisely why stripForwarderHeader exists on
// the receiving side here; inflicting the same thing on whatever is
// downstream would be repeating a mistake we had to write code to survive.
ServiceWSJTRelay ServiceType = "wsjt_relay"
// ServiceWSJTLog wraps the same ADIF in a WSJT-X "Logged ADIF" datagram.
// Logger32 and others listen on the WSJT-X interface, not for plain text, and
// discard a bare ADIF record without a word — so the two cannot be one row.
@@ -0,0 +1,75 @@
package udp
import (
"bytes"
"encoding/binary"
"testing"
)
// A grid reaches OpsLog in TWO kinds of message, not one. The reply that
// answers a CQ with a locator — "C91RU IZ5EME JN52" — is the commonest of them
// on a busy band, and reading only CQs meant any station whose CQ we happened
// to miss had no known grid at all, so it could never be flagged as a new one.
func TestDecodeGridFromBothMessageShapes(t *testing.T) {
for _, tc := range []struct {
msg string
call string
cq bool
grid string
why string
}{
{"CQ RW9MZ MO74", "RW9MZ", true, "MO74", "a plain CQ"},
{"CQ DX F4BPO JN36", "F4BPO", true, "JN36", "a CQ with a modifier"},
{"C91RU IZ5EME JN52", "IZ5EME", false, "JN52", "a reply answering with its grid"},
{"YB1EWD YO6GLT KN36", "YO6GLT", false, "KN36", "the same, other way round"},
// Everything else in the third slot is a report or a sign-off, and must
// never be mistaken for a locator.
{"C91RU IZ5EME -05", "IZ5EME", false, "", "a signal report is not a grid"},
{"C91RU IZ5EME R-05", "IZ5EME", false, "", "a rogered report is not a grid"},
{"C91RU IZ5EME RRR", "IZ5EME", false, "", "RRR is not a grid"},
{"C91RU IZ5EME RR73", "IZ5EME", false, "", "RR73 has the shape of one and is not"},
{"C91RU IZ5EME 73", "IZ5EME", false, "", "73 is not a grid"},
{"<YO7LBX> 8F81SU RR73", "8F81SU", false, "", "a hashed call, signing off"},
} {
call, cq, grid := wsjtSender(tc.msg)
if call != tc.call || cq != tc.cq || grid != tc.grid {
t.Errorf("wsjtSender(%q) = (%q, %v, %q), want (%q, %v, %q) — %s",
tc.msg, call, cq, grid, tc.call, tc.cq, tc.grid, tc.why)
}
}
}
// The Reply replays the decode field for field, so the message must survive the
// trip UNTRIMMED. DecodeMsg is trimmed for display; DecodeMsgRaw is what goes
// back, and a stripped trailing space is a mismatch the far end reports nowhere.
func TestDecodeKeepsTheUntrimmedMessage(t *testing.T) {
qstr := func(b *bytes.Buffer, v string) {
binary.Write(b, binary.BigEndian, int32(len(v)))
b.WriteString(v)
}
const padded = "CQ RW9MZ MO74 "
var p bytes.Buffer
binary.Write(&p, binary.BigEndian, uint32(wsjtMagic))
binary.Write(&p, binary.BigEndian, uint32(2))
binary.Write(&p, binary.BigEndian, uint32(wsjtMsgDecode))
qstr(&p, "JTDX")
binary.Write(&p, binary.BigEndian, uint8(1))
binary.Write(&p, binary.BigEndian, uint32(45_000_000))
binary.Write(&p, binary.BigEndian, int32(-6))
binary.Write(&p, binary.BigEndian, float64(0.2))
binary.Write(&p, binary.BigEndian, uint32(1500))
qstr(&p, "~")
qstr(&p, padded)
ev, ok, err := ParseWSJT(p.Bytes())
if err != nil || !ok {
t.Fatalf("parse: %v ok=%v", err, ok)
}
if ev.DecodeMsgRaw != padded {
t.Errorf("DecodeMsgRaw = %q, want %q — a Reply built from this will not match", ev.DecodeMsgRaw, padded)
}
if ev.DecodeMsg != "CQ RW9MZ MO74" {
t.Errorf("DecodeMsg = %q, want it trimmed for display", ev.DecodeMsg)
}
}
+57 -1
View File
@@ -1,6 +1,11 @@
package udp
import "testing"
import (
"bytes"
"encoding/binary"
"net"
"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
@@ -57,3 +62,54 @@ func upper(s string) string {
}
return string(out)
}
// Resolving the marker must not DESTROY it. A Reply is matched by the receiving
// application against its own decode list field for field, and the mode field it
// compares is the one it sent — the marker. Sending the resolved name instead is
// accepted silently and simply never transmits: JTDX finds no matching decode,
// and no error is reported anywhere to say so. Reported as "clicking a CQ does
// nothing in JTDX".
//
// So a Decode has to carry BOTH: Mode for the log and the status resolver,
// DecodeModeRaw for the Reply.
func TestDecodeKeepsTheRawModeMarkerForReplies(t *testing.T) {
qstr := func(b *bytes.Buffer, v string) {
binary.Write(b, binary.BigEndian, int32(len(v)))
b.WriteString(v)
}
var p bytes.Buffer
binary.Write(&p, binary.BigEndian, uint32(wsjtMagic))
binary.Write(&p, binary.BigEndian, uint32(2)) // schema
binary.Write(&p, binary.BigEndian, uint32(wsjtMsgDecode)) // type 2
qstr(&p, "JTDX") // id
binary.Write(&p, binary.BigEndian, uint8(1)) // is_new
binary.Write(&p, binary.BigEndian, uint32(45_000_000)) // time (ms since midnight)
binary.Write(&p, binary.BigEndian, int32(-6)) // snr
binary.Write(&p, binary.BigEndian, float64(0.2)) // delta_time
binary.Write(&p, binary.BigEndian, uint32(1500)) // delta_frequency
qstr(&p, "~") // mode — the MARKER
qstr(&p, "CQ EN35UKR") // message
out := make(chan Event, 4)
s := &Server{
out: out,
cfg: Config{Name: "JTDX", ServiceType: ServiceWSJT},
lastMode: map[string]string{"JTDX": "FT8"},
dialHz: map[string]int64{"JTDX": 14074000},
}
s.handle(p.Bytes(), &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2237})
var ev Event
select {
case ev = <-out:
default:
t.Fatal("no event emitted for the decode")
}
if ev.Mode != "FT8" {
t.Errorf("resolved Mode = %q, want FT8 (what the log and the status resolver need)", ev.Mode)
}
if ev.DecodeModeRaw != "~" {
t.Errorf("DecodeModeRaw = %q, want %q — a Reply built from this will not match and JTDX will not transmit",
ev.DecodeModeRaw, "~")
}
}
+39 -21
View File
@@ -5,31 +5,50 @@ import (
"time"
)
const ms = 1000
func sec(h, m, s int) uint32 { return uint32((h*3600 + m*60 + s) * ms) }
// WSJT-X stamps a decode with a time of DAY and no date, so the date has to come
// from our own clock — and around midnight the two disagree. A decode stamped
// 23:59:58 that reaches us at 00:00:01 would be dated the NEW day, putting it
// almost 24 hours in the future: it would sort to the top of the decodes panel
// and stay there for the rest of the session, and its period would never line up
// with the ones around it.
//
// The clock is passed IN. The previous version of this test read the real one
// and so failed every afternoon — a 23:59:58 stamp seen at 17:00 is genuinely
// hours in the future, and no rule can make it otherwise. A suite that is red
// for half the day is a suite nobody reads.
func TestDecodeTimeCrossesMidnight(t *testing.T) {
const ms = 1000
sec := func(h, m, s int) uint32 { return uint32((h*3600 + m*60 + s) * ms) }
got := decodeTime(sec(23, 59, 58))
now := time.Now().UTC()
// Whatever the clock says, a decode must never land in the future beyond the
// slack of a single period, nor more than a day in the past.
if d := got.Sub(now); d > time.Minute {
t.Errorf("decode at 23:59:58 resolved to %s, %s in the FUTURE", got.Format(time.RFC3339), d)
// Just after midnight, a stamp from the last seconds of yesterday.
now := time.Date(2026, 3, 14, 0, 0, 1, 0, time.UTC)
got := decodeTimeAt(sec(23, 59, 58), now)
if want := time.Date(2026, 3, 13, 23, 59, 58, 0, time.UTC); !got.Equal(want) {
t.Errorf("23:59:58 seen at 00:00:01 → %s, want %s (yesterday)", got, want)
}
if d := now.Sub(got); d > 24*time.Hour {
t.Errorf("decode at 23:59:58 resolved to %s, %s in the past", got.Format(time.RFC3339), d)
if got.After(now) {
t.Errorf("%s is in the future — it would sort to the top of the list for the session", got)
}
}
// And the ordinary case: a stamp close to now stays on today.
near := decodeTime(sec(now.Hour(), now.Minute(), now.Second()))
if diff := near.Sub(now); diff > 2*time.Second || diff < -2*time.Second {
t.Errorf("a decode stamped at the current time resolved to %s (%s off)", near.Format(time.RFC3339), diff)
// The mirror case: just before midnight, a stamp from the first seconds of
// tomorrow. Our clock is still on the old day.
func TestDecodeTimeCrossesMidnightBackwards(t *testing.T) {
now := time.Date(2026, 3, 13, 23, 59, 58, 0, time.UTC)
got := decodeTimeAt(sec(0, 0, 1), now)
if want := time.Date(2026, 3, 14, 0, 0, 1, 0, time.UTC); !got.Equal(want) {
t.Errorf("00:00:01 seen at 23:59:58 → %s, want %s (tomorrow)", got, want)
}
}
// The ordinary case, which is every decode that is not within a few seconds of
// midnight: the stamp belongs to today and is left alone.
func TestDecodeTimeOrdinary(t *testing.T) {
now := time.Date(2026, 3, 13, 14, 30, 0, 0, time.UTC)
got := decodeTimeAt(sec(14, 29, 45), now)
if want := time.Date(2026, 3, 13, 14, 29, 45, 0, time.UTC); !got.Equal(want) {
t.Errorf("a stamp close to now → %s, want %s", got, want)
}
}
@@ -37,16 +56,15 @@ func TestDecodeTimeCrossesMidnight(t *testing.T) {
// fifteen-second slot must floor to the same period however far apart in the
// slot they were heard.
func TestDecodesInOneSlotShareAPeriod(t *testing.T) {
const ms = 1000
at := func(h, m, s int) time.Time { return decodeTime(uint32((h*3600 + m*60 + s) * ms)) }
now := time.Date(2026, 3, 13, 12, 30, 7, 0, time.UTC)
at := func(h, m, s int) time.Time { return decodeTimeAt(sec(h, m, s), now) }
floor := func(x time.Time) int64 { return x.Unix() / 15 * 15 }
a, b := at(12, 30, 0), at(12, 30, 14)
if floor(a) != floor(b) {
t.Errorf("12:30:00 and 12:30:14 fell in different periods (%d vs %d)", floor(a), floor(b))
t.Errorf("%s and %s fell in different periods", a, b)
}
c := at(12, 30, 15)
if floor(a) == floor(c) {
t.Error("12:30:00 and 12:30:15 shared a period — the slot boundary was not honoured")
if floor(at(12, 30, 14)) == floor(at(12, 30, 15)) {
t.Error("12:30:14 and 12:30:15 are different slots and must not share a period")
}
}
+92
View File
@@ -2,6 +2,7 @@ package udp
import (
"fmt"
"net"
"strings"
"hamlog/internal/applog"
@@ -185,3 +186,94 @@ func (m *Manager) sendTo(c Config, payload []byte) {
applog.Printf("udp: [%s] sent %d bytes to %s (%s)%s",
c.Name, len(payload), dst, c.ServiceType, sentPreview(c, payload))
}
// RelayInbound re-sends one received datagram, unchanged, to every configured
// relay destination.
//
// Called with the RAW bytes as they arrived — before parsing, and whatever the
// parse made of them. A packet this build cannot decode is still a packet the
// application downstream may understand perfectly well, and a relay that only
// forwards what it understood is a relay that silently drops the fields it has
// not learned about yet.
//
// Loop guard: a destination that is one of our OWN inbound ports on a local
// address would come straight back in and be relayed again, for ever, at line
// rate. Such a row is skipped and said so once — quietly dropping it would look
// like a relay that does not work.
func (m *Manager) RelayInbound(pkt []byte, fromPort int) {
if len(pkt) == 0 {
return
}
rows := m.Outbound(ServiceWSJTRelay)
if len(rows) == 0 {
return
}
m.mu.Lock()
ports := make(map[int]struct{}, len(m.inbound))
for _, s := range m.inbound {
ports[s.cfg.Port] = struct{}{}
}
m.mu.Unlock()
for _, c := range rows {
if isLoopback(c.DestinationIP) {
if _, mine := ports[c.Port]; mine {
m.relayLoopOnce(c)
continue
}
}
// Deliberately not sendTo: that logs a line per datagram, and this runs
// on every decode of every period — it would bury the log within minutes.
host := strings.TrimSpace(c.DestinationIP)
if host == "" {
host = "127.0.0.1"
}
dst := fmt.Sprintf("%s:%d", host, c.Port)
if err := SendUDP(dst, pkt); err != nil {
m.relayErrOnce(c, dst, err)
}
}
_ = fromPort
}
// isLoopback reports whether a destination names this machine. Empty counts:
// sendTo defaults it to 127.0.0.1.
func isLoopback(host string) bool {
h := strings.TrimSpace(host)
if h == "" || h == "localhost" {
return true
}
ip := net.ParseIP(h)
return ip != nil && ip.IsLoopback()
}
// relayLoopOnce and relayErrOnce keep a repeating relay complaint to one line a
// session. Both fire per datagram otherwise, which on a busy band is hundreds a
// minute and makes the log useless for anything else.
func (m *Manager) relayLoopOnce(c Config) {
m.relayWarnMu.Lock()
defer m.relayWarnMu.Unlock()
if m.relayWarned == nil {
m.relayWarned = map[int64]bool{}
}
if m.relayWarned[c.ID] {
return
}
m.relayWarned[c.ID] = true
applog.Printf("udp: [%s] relay target %s:%d is one of OpsLog's OWN listening ports — "+
"that would feed the stream back into itself for ever. Nothing is relayed on this row; "+
"point it at the other application's port.", c.Name, c.DestinationIP, c.Port)
}
func (m *Manager) relayErrOnce(c Config, dst string, err error) {
m.relayWarnMu.Lock()
defer m.relayWarnMu.Unlock()
if m.relayWarned == nil {
m.relayWarned = map[int64]bool{}
}
if m.relayWarned[-c.ID-1] {
return
}
m.relayWarned[-c.ID-1] = true
applog.Printf("udp: [%s] relay to %s failed: %v (said once)", c.Name, dst, err)
}
+77
View File
@@ -0,0 +1,77 @@
package udp
import (
"net"
"testing"
"time"
)
// The relay exists so a second application can be fed the same WSJT-X stream,
// and the ONE thing it must get right is that the bytes are unchanged. The
// relays in the field prepend an origin header ("127.0.0.1:2237|") — which is
// why stripForwarderHeader had to be written on the receiving side — so a
// header here would inflict on the next program the fault we had to survive.
func TestRelayForwardsVerbatim(t *testing.T) {
dst, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
if err != nil {
t.Fatal(err)
}
defer dst.Close()
port := dst.LocalAddr().(*net.UDPAddr).Port
m := &Manager{outbound: []Config{{
ID: 1, Name: "relay", ServiceType: ServiceWSJTRelay,
DestinationIP: "127.0.0.1", Port: port,
}}}
// A packet with bytes that no parser here understands, on purpose: the relay
// must not care what it is carrying.
sent := []byte{0xad, 0xbc, 0xcb, 0xda, 0x00, 0x00, 0x00, 0x63, 0xde, 0xad, 0xbe, 0xef}
m.RelayInbound(sent, 2237)
buf := make([]byte, 1024)
_ = dst.SetReadDeadline(time.Now().Add(2 * time.Second))
n, _, err := dst.ReadFromUDP(buf)
if err != nil {
t.Fatalf("nothing relayed: %v", err)
}
if got := buf[:n]; string(got) != string(sent) {
t.Errorf("relayed % X, want % X — the bytes must go out unchanged", got, sent)
}
}
// A relay aimed at one of OpsLog's OWN listening ports would come straight back
// in, be relayed again, and saturate the loopback within seconds. The row is
// skipped instead.
func TestRelayRefusesToFeedItself(t *testing.T) {
own, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
if err != nil {
t.Fatal(err)
}
defer own.Close()
port := own.LocalAddr().(*net.UDPAddr).Port
m := &Manager{
inbound: map[int64]*Server{
7: {cfg: Config{ID: 7, Port: port, ServiceType: ServiceWSJT}},
},
outbound: []Config{{
ID: 1, Name: "loop", ServiceType: ServiceWSJTRelay,
DestinationIP: "127.0.0.1", Port: port,
}},
}
m.RelayInbound([]byte{1, 2, 3, 4}, 2237)
buf := make([]byte, 64)
_ = own.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
if n, _, err := own.ReadFromUDP(buf); err == nil {
t.Fatalf("relayed %d bytes back into our own listener — that is the loop", n)
}
}
// No relay rows configured must cost nothing and send nothing.
func TestRelayWithNoRowsIsSilent(t *testing.T) {
m := &Manager{}
m.RelayInbound([]byte{1, 2, 3}, 2237) // must not panic
m.RelayInbound(nil, 2237)
}
+56 -6
View File
@@ -85,7 +85,18 @@ func reusingListenConfig() net.ListenConfig {
// of the list for the rest of the session. More than half a day apart is read as
// the wrong side of midnight and moved.
func decodeTime(msSinceMidnight uint32) time.Time {
now := time.Now().UTC()
return decodeTimeAt(msSinceMidnight, time.Now().UTC())
}
// decodeTimeAt is decodeTime with the clock passed in.
//
// Split out so the midnight rule can be tested at midnight instead of at
// whatever time the suite happens to run: the test used to read the real clock
// and failed every afternoon, because a 23:59:58 stamp seen at 17:00 is hours in
// the future and no rule can make it otherwise. A test that fails for half the
// day teaches everyone to ignore the suite.
func decodeTimeAt(msSinceMidnight uint32, now time.Time) time.Time {
now = now.UTC()
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
at := midnight.Add(time.Duration(msSinceMidnight) * time.Millisecond)
switch {
@@ -112,7 +123,7 @@ type Event struct {
// A WSJT-X Decode (heard station) to render on the panadapter.
DecodeCall string // transmitting (DE) callsign
DecodeGrid string // 4-char grid, CQ decodes only
DecodeGrid string // 4-char grid: from a CQ, or a reply answering with its locator
DecodeFreqHz int64 // RF frequency (dial + audio offset)
DecodeSNR int // reported SNR (dB)
DecodeCQ bool // the decode was a CQ
@@ -134,6 +145,17 @@ type Event struct {
DecodeAudioHz int64 // audio offset inside the passband
DecodeMs uint32 // the decode's raw ms-since-midnight, as sent
DecodeLowConf bool
// DecodeModeRaw is the mode field EXACTLY as the Decode carried it — the
// one-character marker ("~", "+"), not the resolved name in Mode.
//
// Both are needed and they are not interchangeable. Mode is what the log and
// the status resolver want. This is what a Reply must echo: the receiving
// application matches a Reply against its own decode list field for field,
// and JTDX rejects one whose mode reads "FT8" where it decoded "~" — the
// click is accepted, nothing is transmitted, and nothing is reported.
DecodeModeRaw string
// DecodeMsgRaw is the message as sent, untrimmed — see DecodeModeRaw.
DecodeMsgRaw string
// ProgramID is the sending application's own id ("WSJT-X", "MSHV", or
// "WSJT-X - 2" for a second instance started with --rig-name). It is what
// tells two receivers apart on one multicast group — and it is the address a
@@ -145,7 +167,11 @@ type Event struct {
// true while the carrier is actually up. From Status, so ~1 Hz.
TxMessage string
Transmitting bool
DECall string // the operator's own call, as the digital app knows it
// TxEnabled is the sender's Enable Tx toggle — see WSJTEvent.TxEnabled. The
// watchdog clears it, which is the only reliable sign an exchange was
// abandoned rather than merely paused between overs.
TxEnabled bool
DECall string // the operator's own call, as the digital app knows it
// ClearCall is set when a WSJT/JTDX/MSHV Status message reports an EMPTY DX
// Call after previously reporting one — i.e. the operator cleared the call in
@@ -162,7 +188,10 @@ type Event struct {
// Server is a single inbound UDP listener.
type Server struct {
cfg Config
cfg Config
// mgr is the owning Manager, so a listener can reach the outbound rows.
// The relay is the only thing that needs it.
mgr *Manager
conn *net.UDPConn
out chan<- Event
stop chan struct{}
@@ -230,10 +259,11 @@ func describePacket(pkt []byte) string {
return fmt.Sprintf("%d bytes | text %q%s | hex %s%s", len(pkt), text.String(), more, strings.TrimSpace(hex.String()), more)
}
func newServer(cfg Config, out chan<- Event) *Server {
func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server {
return &Server{
cfg: cfg,
out: out,
mgr: mgr,
stop: make(chan struct{}),
done: make(chan struct{}),
}
@@ -399,6 +429,17 @@ func (s *Server) noteIgnoredADIF(pkt []byte) {
}
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// Relay FIRST, and unconditionally: before parsing, and whatever the parse
// then makes of it. A datagram this build cannot decode is still one the
// application downstream may understand, and a relay that forwards only what
// it understood drops exactly the fields it has yet to learn about.
//
// WSJT listeners only. The other inbound services are text protocols with no
// second consumer to speak of, and relaying an ADIF record twice would log
// the contact twice.
if s.cfg.ServiceType == ServiceWSJT && s.mgr != nil {
s.mgr.RelayInbound(pkt, s.cfg.Port)
}
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
switch s.cfg.ServiceType {
case ServiceWSJT:
@@ -477,7 +518,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.DecodeSNR = w.SNR
ev.DecodeCQ = w.IsCQ
ev.Mode = DecodeModeName(w.Mode, statusMode)
ev.DecodeModeRaw = w.Mode
ev.DecodeMsg = w.DecodeMsg
ev.DecodeMsgRaw = w.DecodeMsgRaw
ev.DecodeAt = decodeTime(w.DecodeMsSinceMidnight)
ev.DecodeTRPeriod = tr
ev.DecodeDial = dial
@@ -677,6 +720,13 @@ type Manager struct {
httpFailMu sync.Mutex
httpFailAt map[int64]time.Time
// relayWarned keeps a relay row's complaint (a loop target, a dead
// destination) to one line a session. Keyed by row id for the loop warning
// and by -id-1 for the send failure, so one row can say each once.
// Without this a busy band writes hundreds of identical lines a minute.
relayWarnMu sync.Mutex
relayWarned map[int64]bool
mu sync.Mutex
inbound map[int64]*Server
outbound []Config
@@ -729,7 +779,7 @@ func (m *Manager) Reload(ctx context.Context) []string {
m.mu.Unlock()
continue
}
srv := newServer(c, m.out)
srv := newServer(c, m.out, m)
if err := srv.start(); err != nil {
applog.Printf("udp: start %q failed: %v", c.Name, err)
errs = append(errs, fmt.Sprintf("%s: %v", c.Name, err))
+31 -3
View File
@@ -51,7 +51,7 @@ type WSJTEvent struct {
// known dial frequency (from Status) to DeltaFreqHz to get the RF frequency.
IsDecode bool
DecodeCall string // the sender (DE) callsign extracted from the message text
DecodeGrid string // 4-char grid, CQ decodes only — the exchange carries none
DecodeGrid string // 4-char grid: from a CQ, or a reply that answers with its locator
DeltaFreqHz int64 // audio offset within the passband (Hz)
SNR int // reported signal-to-noise (dB)
IsCQ bool // the decode was a CQ call
@@ -64,6 +64,12 @@ type WSJTEvent struct {
// exchange is what tells an operator where a station is in a QSO, and no set
// of extracted fields says "R-09" the way the line itself does.
DecodeMsg string
// DecodeMsgRaw is the message EXACTLY as the datagram carried it, trailing
// spaces and all. DecodeMsg above is trimmed for display; a Reply has to
// send this one, because the receiving application matches a Reply against
// its own decode list field for field and a stripped space is a mismatch it
// reports nowhere.
DecodeMsgRaw string
// DecodeMsSinceMidnight is the decode's own timestamp, in milliseconds since
// 00:00 UTC, as the sender reported it. It is what groups decodes into T/R
// PERIODS — arrival time cannot, since a whole period's decodes land in one
@@ -80,8 +86,14 @@ type WSJTEvent struct {
// from Status, so they arrive about once a second.
TxMessage string
Transmitting bool
DECall string // the operator's own callsign, as the digital app knows it
DEGrid string // and their square
// TxEnabled is the sender's "Enable Tx" toggle. It is what the WATCHDOG
// turns off: the application stops transmitting but leaves the DX call set,
// so a caller that reads only the DX call believes the QSO is still running
// and waits for ever. This is the difference between "between overs" and
// "given up".
TxEnabled bool
DECall string // the operator's own callsign, as the digital app knows it
DEGrid string // and their square
// TRPeriod is the transmit/receive period in seconds (15 for FT8, 7 or 8 for
// FT4 depending on the sender's rounding). The authority on how long a slot
// is — better than inferring it from the mode name, which says nothing about
@@ -214,6 +226,7 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
}
}
ev.Transmitting = transmitting != 0
ev.TxEnabled = txEnabled != 0
// rx_df, tx_df
var i32 int32
for i := 0; i < 2; i++ {
@@ -331,6 +344,7 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
ev.SNR = int(snr)
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
ev.DecodeMsg = strings.TrimSpace(msg)
ev.DecodeMsgRaw = msg
ev.DecodeMsSinceMidnight = t32
ev.LowConfidence = lowConf != 0
ev.OffAir = offAir != 0
@@ -386,7 +400,21 @@ func wsjtSender(message string) (call string, isCQ bool, grid string) {
return "", true, ""
}
// Standard exchange: the DE (sender) call is the second token.
//
// And the THIRD token is a grid whenever the station is answering with its
// locator — "C91RU IZ5EME JN52", the ordinary first reply to a CQ. This used
// to return no grid at all on the grounds that "the exchange carries none",
// which is simply not true: it is the commonest grid-bearing message on a
// busy band, and only a CQ being read meant a station whose CQ we missed had
// no known grid — so it could never be flagged as a new one. GridTracker
// reads both, which is why its wanted-grid count ran so far ahead of ours.
//
// isGridField is what keeps the reports and sign-offs out: -05, R-05, RRR,
// 73 and RR73 all fail it, on length, charset or by name.
if len(f) >= 2 && looksLikeCall(f[1]) {
if len(f) >= 3 && isGridField(f[2]) {
return f[1], false, f[2]
}
return f[1], false, ""
}
return "", false, ""
+78
View File
@@ -0,0 +1,78 @@
package udp
import (
"bytes"
"encoding/binary"
"fmt"
"strings"
"hamlog/internal/applog"
)
// WSJT-X Free Text (message type 9) — "put this in the free-text box, and send".
//
// Free Text type 9
// id utf8 the target application's own id
// text utf8 the message
// send bool true = transmit it now, false = only fill the box
//
// What this is FOR, and what it is not:
//
// It is the one "transmit this now" primitive that behaves the same on WSJT-X,
// WSJT-Z, JTDX, JTDX-Improved and MSHV, because it needs no decode to match
// against — unlike Reply, which the receiving application will only action if it
// can find the exact decode being answered.
//
// It is NOT a way to call a station. FT8 free text is 13 characters, so a
// standard "<DX> <ME> <GRID>" does not fit, and more importantly the text goes
// out AS free text without setting the DX Call — so the sequencer never takes
// over, and no report, RR73 or logging prompt follows. Use it for a 73, a short
// note or a CQ; use Reply to start a QSO.
const wsjtMsgFreeText = 9
// EncodeFreeText builds the datagram. send=false fills the box without keying.
func EncodeFreeText(programID, text string, send bool) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2)) // schema 2 — what every current sender speaks
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgFreeText))
writeQString(&b, programID)
writeQString(&b, text)
var s uint8
if send {
s = 1
}
_ = binary.Write(&b, binary.BigEndian, s)
return b.Bytes()
}
// SendFreeText hands a free-text message to one decoding application.
//
// Routed by program id to the address that instance's packets arrive from, for
// the same reason as SendReply and SendHaltTx: two receivers can share one
// multicast group, and transmitting from the wrong one is worse than not
// transmitting at all.
func (m *Manager) SendFreeText(programID, text string, send bool) error {
if strings.TrimSpace(programID) == "" {
return fmt.Errorf("no application id — cannot tell which receiver to send with")
}
m.mu.Lock()
servers := make([]*Server, 0, len(m.inbound))
for _, s := range m.inbound {
servers = append(servers, s)
}
m.mu.Unlock()
for _, s := range servers {
conn, addr := s.replyTarget(programID)
if conn == nil || addr == nil {
continue
}
if _, err := conn.WriteToUDP(EncodeFreeText(programID, text, send), addr); err != nil {
return fmt.Errorf("send free text to %s at %s: %w", programID, addr, err)
}
applog.Printf("udp: free text sent to %s at %s — %q (send=%v)", programID, addr, text, send)
return nil
}
return fmt.Errorf("no packet has arrived from %q yet — nothing to send to", programID)
}
@@ -0,0 +1,45 @@
package udp
import (
"bytes"
"testing"
)
// The wire format, byte for byte. A malformed Free Text is discarded in silence
// by the far end, exactly like a malformed Reply — so a wrong byte here shows up
// as a button that does nothing.
func TestEncodeFreeText(t *testing.T) {
got := EncodeFreeText("JTDX", "73 GL", true)
want := []byte{
0xad, 0xbc, 0xcb, 0xda, // magic
0x00, 0x00, 0x00, 0x02, // schema 2
0x00, 0x00, 0x00, 0x09, // type 9 — Free Text
0x00, 0x00, 0x00, 0x04, 'J', 'T', 'D', 'X',
0x00, 0x00, 0x00, 0x05, '7', '3', ' ', 'G', 'L',
0x01, // send now
}
if !bytes.Equal(got, want) {
t.Errorf("EncodeFreeText\n got %#v\nwant %#v", got, want)
}
}
// send=false fills the box without keying — the difference is one byte, and
// getting it backwards would transmit when the operator only meant to prepare.
func TestEncodeFreeTextWithoutSending(t *testing.T) {
got := EncodeFreeText("MSHV", "", false)
if got[len(got)-1] != 0x00 {
t.Errorf("send flag = %#x, want 0 — this would transmit unasked", got[len(got)-1])
}
// An empty text is a zero-length QString, never the -1 that means null:
// WSJT-X drops a packet with a null where it expects text.
if !bytes.Equal(got[len(got)-5:len(got)-1], []byte{0, 0, 0, 0}) {
t.Errorf("empty text was not encoded as a zero-length string: % X", got)
}
}
func TestSendFreeTextRejectsEmptyID(t *testing.T) {
m := &Manager{}
if err := m.SendFreeText(" ", "73", true); err == nil {
t.Fatal("expected an error for a blank program id")
}
}
+73
View File
@@ -0,0 +1,73 @@
package udp
import (
"bytes"
"encoding/binary"
"fmt"
"strings"
"hamlog/internal/applog"
)
// WSJT-X Halt Tx (message type 8) — "stop transmitting".
//
// The same two things the Halt Tx button and the Enable Tx toggle do in the
// application's own window:
//
// Halt Tx type 8
// id utf8 the target application's own id
// auto_tx_only bool false = stop the transmission NOW
// true = let this over finish, then stop auto-Tx
//
// Both are useful and they are not the same operation. Stopping mid-over is what
// you want when you have called the wrong station or realised the frequency is
// occupied; finishing the over first is what you want when the QSO is complete
// and cutting the transmission would leave the other end waiting for a report
// that never lands.
const wsjtMsgHaltTx = 8
// EncodeHaltTx builds the datagram. autoTxOnly false halts immediately.
func EncodeHaltTx(programID string, autoTxOnly bool) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2)) // schema 2 — what every current sender speaks
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgHaltTx))
writeQString(&b, programID)
var auto uint8
if autoTxOnly {
auto = 1
}
_ = binary.Write(&b, binary.BigEndian, auto)
return b.Bytes()
}
// SendHaltTx tells one decoding application to stop transmitting.
//
// Routed by PROGRAM ID and answered back to the address that instance's packets
// arrive from, exactly like SendReply: two receivers can share one multicast
// group, and halting the 20 m instance because the 6 m one is transmitting would
// be worse than doing nothing.
func (m *Manager) SendHaltTx(programID string, autoTxOnly bool) error {
if strings.TrimSpace(programID) == "" {
return fmt.Errorf("no application id — cannot tell which receiver to halt")
}
m.mu.Lock()
servers := make([]*Server, 0, len(m.inbound))
for _, s := range m.inbound {
servers = append(servers, s)
}
m.mu.Unlock()
for _, s := range servers {
conn, addr := s.replyTarget(programID)
if conn == nil || addr == nil {
continue
}
if _, err := conn.WriteToUDP(EncodeHaltTx(programID, autoTxOnly), addr); err != nil {
return fmt.Errorf("send halt to %s at %s: %w", programID, addr, err)
}
applog.Printf("udp: halt tx sent to %s at %s (auto_tx_only=%v)", programID, addr, autoTxOnly)
return nil
}
return fmt.Errorf("no packet has arrived from %q yet — nothing to halt", programID)
}
@@ -0,0 +1,75 @@
package udp
import (
"bytes"
"testing"
)
// The Halt Tx datagram, byte for byte. A wrong byte here does not fail loudly —
// WSJT-X drops a malformed packet in silence, and the operator sees a Halt
// button that simply does nothing.
func TestEncodeHaltTx(t *testing.T) {
tests := []struct {
name string
id string
autoTxOnly bool
want []byte
}{
{
name: "halt now",
id: "WSJT-X",
want: []byte{
0xad, 0xbc, 0xcb, 0xda, // magic
0x00, 0x00, 0x00, 0x02, // schema 2
0x00, 0x00, 0x00, 0x08, // type 8 — Halt Tx
0x00, 0x00, 0x00, 0x06, // id length
'W', 'S', 'J', 'T', '-', 'X',
0x00, // auto_tx_only = false → stop mid-over
},
},
{
name: "finish the over first",
id: "MSHV",
autoTxOnly: true,
want: []byte{
0xad, 0xbc, 0xcb, 0xda,
0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0x00, 0x08,
0x00, 0x00, 0x00, 0x04,
'M', 'S', 'H', 'V',
0x01,
},
},
{
// An empty id is length 0, never the -1 that means a null QString:
// a null where text is expected makes WSJT-X discard the packet.
name: "empty id is a zero-length string",
id: "",
want: []byte{
0xad, 0xbc, 0xcb, 0xda,
0x00, 0x00, 0x00, 0x02,
0x00, 0x00, 0x00, 0x08,
0x00, 0x00, 0x00, 0x00,
0x00,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := EncodeHaltTx(tc.id, tc.autoTxOnly)
if !bytes.Equal(got, tc.want) {
t.Errorf("EncodeHaltTx(%q, %v)\n got %#v\nwant %#v", tc.id, tc.autoTxOnly, got, tc.want)
}
})
}
}
// SendHaltTx must refuse an empty id rather than broadcast a halt at whatever
// application happens to answer: with two receivers sharing a multicast group,
// halting the wrong one is worse than doing nothing.
func TestSendHaltTxRejectsEmptyID(t *testing.T) {
m := &Manager{}
if err := m.SendHaltTx(" ", false); err == nil {
t.Fatal("expected an error for a blank program id")
}
}
+10 -1
View File
@@ -107,7 +107,16 @@ func (m *Manager) SendReply(r Reply) error {
if _, err := conn.WriteToUDP(pkt, addr); err != nil {
return fmt.Errorf("send reply to %s at %s: %w", r.ProgramID, addr, err)
}
applog.Printf("udp: reply sent to %s at %s — %q", r.ProgramID, addr, r.Message)
// Says whether the decode being answered was a CQ, because that is what
// decides whether the far end will act on it at all: the protocol only
// requires a Reply to be honoured for a CQ or QRZ. Without this the log
// showed a reply going out and nothing happening, and the reason had to
// be deduced from the message text by eye.
kind := "not a CQ — the far end may ignore it"
if strings.HasPrefix(strings.TrimSpace(strings.ToUpper(r.Message)), "CQ ") {
kind = "CQ"
}
applog.Printf("udp: reply sent to %s at %s — %q (%s)", r.ProgramID, addr, r.Message, kind)
return nil
}
return fmt.Errorf("no packet has arrived from %q yet — nothing to answer to", r.ProgramID)