Files
2026-08-20 17:53:55 +02:00

139 lines
5.0 KiB
Go

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)
}
// 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)
}
// 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
}