feat(decodes): answer a station on click, DT and Freq, badge filters

Clicking a decode now ANSWERS it. It sends WSJT-X/MSHV a Reply message
(type 4), which is the same thing as double-clicking the line in their own
Band Activity window: the application looks the decode up, sets its
transmit frequency to the caller's and starts the exchange.

It deliberately does not tune the radio, which is what it did before and
why nothing happened. On FT8 the whole band sits inside one passband, so
moving the dial changes nothing about who gets answered - the decision
belongs to the decoding application, and the Reply is the only way to hand
it over. Tuning would also just fight it for the VFO. The entry is still
filled so the QSO can be logged here.

The reply is routed by PROGRAM ID, not by listener: two receivers can share
one multicast group, and answering a station heard on the 6 m instance by
talking to the 20 m one would start a call on the wrong band. It goes to
the address that instance's packets actually arrive from - a multicast
listener must answer the sender, never the group. WSJT-X matches the reply
against its own decode list, so the payload replays the decode field for
field: time, snr, delta time, audio offset, mode and message text.

Two columns added, DT and Freq - the audio offset inside the passband, not
the RF frequency, which is the same for every station in the list and says
nothing. Past about two seconds DT takes a warning tint: that station is
drifting out of the window.

The transmit strip. "You cannot see what you are sending, or who you are
calling" - two separate faults. The message was only ever threaded into its
period, and in FT8 you transmit in the slots you are NOT receiving in, so
its period had no decodes and the whole line was dropped; a transmit slot
now creates its period. And the state is a strip of its own at the top,
because it is the one thing on the screen that is about the operator rather
than the band. It is fed by every Status rather than only by one carrying
transmit text, so it can still name the station being called on MSHV and
older JTDX builds, which stop before tx_message in the Status payload.

"New only" became per-category badges, in the colours and the vocabulary of
the Chase New panel. None lit shows the whole band - this is a decode log
first, and a panel that opened by hiding most of the traffic would be lying
about what is on the air.
This commit is contained in:
2026-08-18 06:53:10 +02:00
parent d829726679
commit fba7e79a1c
9 changed files with 417 additions and 38 deletions
+129
View File
@@ -0,0 +1,129 @@
package udp
import (
"bytes"
"encoding/binary"
"fmt"
"net"
"strings"
"hamlog/internal/applog"
)
// WSJT-X Reply (message type 4) — "answer this station".
//
// It is the same thing as double-clicking the line in WSJT-X's own Band
// Activity window: the application looks the decode up in its list, sets its
// transmit frequency to the caller's, fills the DX call and starts the exchange.
// Which is why OpsLog cannot do this by tuning the radio — on FT8 the whole band
// sits inside one passband, so moving the dial changes nothing about who gets
// answered. The decision belongs to the decoding application, and this is the
// only way to hand it over.
//
// The payload REPLAYS the decode being answered, and WSJT-X matches it against
// what it decoded. Every field has to come back exactly as it went out — which
// is why the parser now keeps the time, the delta time, the audio offset and the
// message text rather than only what the panadapter needed.
//
// Reply type 4
// id utf8 the target application's own id
// time quint32 ms since midnight, from the decode
// snr qint32
// delta_time double seconds
// delta_frequency quint32 audio offset in the passband, Hz
// mode utf8
// message utf8
// low_confidence bool
// modifiers quint8 keyboard modifiers (0 = a plain click)
const wsjtMsgReply = 4
// Reply is one "call this station" request, rebuilt from a decode.
type Reply struct {
ProgramID string // which application to talk to ("WSJT-X", "MSHV", "WSJT-X - 2")
MsSinceMidnig uint32
SNR int32
DeltaTime float64
DeltaFreqHz uint32
Mode string
Message string
LowConfidence bool
}
// writeQString writes a Qt QString/QUtf8: a big-endian int32 length then the
// bytes. An EMPTY string is length 0, not the -1 that means null — WSJT-X reads
// a null where it expects text as a malformed packet and drops the whole reply.
func writeQString(b *bytes.Buffer, s string) {
_ = binary.Write(b, binary.BigEndian, int32(len(s)))
b.WriteString(s)
}
// EncodeReply builds the datagram.
func EncodeReply(r Reply) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2)) // schema 2 — the one every current sender speaks
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgReply))
writeQString(&b, r.ProgramID)
_ = binary.Write(&b, binary.BigEndian, r.MsSinceMidnig)
_ = binary.Write(&b, binary.BigEndian, r.SNR)
_ = binary.Write(&b, binary.BigEndian, r.DeltaTime)
_ = binary.Write(&b, binary.BigEndian, r.DeltaFreqHz)
writeQString(&b, r.Mode)
writeQString(&b, r.Message)
var low uint8
if r.LowConfidence {
low = 1
}
_ = binary.Write(&b, binary.BigEndian, low)
_ = binary.Write(&b, binary.BigEndian, uint8(0)) // modifiers: a plain click
return b.Bytes()
}
// SendReply hands a Reply to the application that produced the decode.
//
// Routed by PROGRAM ID, not by listener: two receivers can share one multicast
// group, and answering a station heard on the 6 m instance by talking to the
// 20 m one would start a call on the wrong band. The id is what tells them
// apart, and the address the reply goes to is the one that instance's packets
// actually arrive from — a multicast listener must answer back to the sender,
// not to the group.
func (m *Manager) SendReply(r Reply) error {
if strings.TrimSpace(r.ProgramID) == "" {
return fmt.Errorf("no application id — cannot tell which receiver to answer 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(r.ProgramID)
if conn == nil || addr == nil {
continue
}
pkt := EncodeReply(r)
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)
return nil
}
return fmt.Errorf("no packet has arrived from %q yet — nothing to answer to", r.ProgramID)
}
// replyTarget returns this listener's socket and the address the given program
// last sent from, or nils when it has never been heard here.
func (s *Server) replyTarget(programID string) (*net.UDPConn, *net.UDPAddr) {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn == nil || s.lastFrom == nil {
return nil, nil
}
addr, ok := s.lastFrom[programID]
if !ok {
return nil, nil
}
return s.conn, addr
}