139 lines
5.0 KiB
Go
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
|
|
}
|