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 }