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
+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))