Files
OpsLog/internal/integrations/udp/wsjthighlight.go
T
rouggy 3c59507bc3 feat(udp): Highlight Callsign and Replay — the decoder becomes log-aware
Message 13 paints callsigns in WSJT-X/JTDX's own Band Activity window with
verdicts from the same cluster status cache that colours the spot grid:
watchlist pink, new DXCC green, new band for its entity orange. Deduplicated
per instance+call+verdict, one datagram each; a verdict that lapses (the
operator worked them) clears that call, and turning the option off clears
everything via the protocol's CLEARALL!. Off by default, switch in the
Connections panel.

Message 7 asks a program heard for the first time this session to replay the
decodes already on its screen, so the FT decodes panel starts full instead of
empty until the next period. Replayed lines arrive marked not-new and are
shown but never auto-answered — the auto-caller now checks, on top of its
30-second freshness gate.
2026-08-30 15:25:58 +02:00

147 lines
4.8 KiB
Go

package udp
import (
"bytes"
"encoding/binary"
"fmt"
"strings"
"hamlog/internal/applog"
)
// WSJT-X Highlight Callsign (13) and Replay (7) — the two halves of making the
// Band Activity window log-aware.
//
// Highlight paints a callsign in the decoding application's own window with the
// colours OpsLog chooses — new DXCC, new band, a watchlist member — the way
// JTAlert does. Replay asks a freshly-discovered instance to resend the decodes
// it already has on screen, so the FT decodes panel starts full instead of
// empty until the next period.
const (
wsjtMsgReplay = 7
wsjtMsgHighlight = 13
)
// RGB is one highlight colour. A nil *RGB means "invalid QColor", which is the
// protocol's way of saying "remove the highlight".
type RGB struct{ R, G, B uint8 }
// writeQColor serializes a QColor as QDataStream does: a spec byte (1 = RGB,
// 0 = invalid) followed by five 16-bit channels (alpha, red, green, blue, pad),
// each 8-bit value doubled into 16 bits the way Qt stores them.
func writeQColor(b *bytes.Buffer, c *RGB) {
if c == nil {
b.WriteByte(0) // invalid — clears the highlight
for i := 0; i < 5; i++ {
_ = binary.Write(b, binary.BigEndian, uint16(0))
}
return
}
b.WriteByte(1) // spec = RGB
wide := func(v uint8) uint16 { return uint16(v) * 0x101 }
_ = binary.Write(b, binary.BigEndian, uint16(0xFFFF)) // alpha, opaque
_ = binary.Write(b, binary.BigEndian, wide(c.R))
_ = binary.Write(b, binary.BigEndian, wide(c.G))
_ = binary.Write(b, binary.BigEndian, wide(c.B))
_ = binary.Write(b, binary.BigEndian, uint16(0)) // pad
}
// EncodeHighlight builds a Highlight Callsign datagram. bg/fg nil = invalid
// colour; both nil clears the callsign's highlight.
func EncodeHighlight(programID, callsign string, bg, fg *RGB, lastPeriodOnly bool) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2))
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgHighlight))
writeQString(&b, programID)
writeQString(&b, callsign)
writeQColor(&b, bg)
writeQColor(&b, fg)
var last uint8
if lastPeriodOnly {
last = 1
}
_ = binary.Write(&b, binary.BigEndian, last)
return b.Bytes()
}
// EncodeReplay builds a Replay datagram — "resend what your window holds".
func EncodeReplay(programID string) []byte {
var b bytes.Buffer
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
_ = binary.Write(&b, binary.BigEndian, uint32(2))
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgReplay))
writeQString(&b, programID)
return b.Bytes()
}
// sendToInstance routes a raw datagram to the application that owns programID,
// the same way SendReply does: to the address its packets actually arrive from.
func (m *Manager) sendToInstance(programID string, pkt []byte, what string) error {
if strings.TrimSpace(programID) == "" {
return fmt.Errorf("no application id")
}
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(programID)
if conn == nil || addr == nil {
continue
}
if _, err := conn.WriteToUDP(pkt, addr); err != nil {
return fmt.Errorf("send %s to %s at %s: %w", what, programID, addr, err)
}
return nil
}
return fmt.Errorf("no packet has arrived from %q yet", programID)
}
// SendHighlight paints (or clears) one callsign in the given instance.
func (m *Manager) SendHighlight(programID, callsign string, bg, fg *RGB, lastPeriodOnly bool) error {
return m.sendToInstance(programID, EncodeHighlight(programID, callsign, bg, fg, lastPeriodOnly), "highlight")
}
// SendClearHighlights removes every highlighting instruction OpsLog installed
// in the instance. "CLEARALL!" is the protocol's own magic callsign for it.
func (m *Manager) SendClearHighlights(programID string) error {
return m.sendToInstance(programID, EncodeHighlight(programID, "CLEARALL!", nil, nil, false), "clear-highlights")
}
// SendReplay asks the instance to resend its on-screen decodes.
func (m *Manager) SendReplay(programID string) error {
err := m.sendToInstance(programID, EncodeReplay(programID), "replay")
if err == nil {
applog.Printf("udp: replay requested from %q — its existing decodes will arrive marked not-new", programID)
}
return err
}
// Instances lists every program id a packet has arrived from, for "clear the
// highlights everywhere" and the startup replay.
func (m *Manager) Instances() []string {
m.mu.Lock()
servers := make([]*Server, 0, len(m.inbound))
for _, s := range m.inbound {
servers = append(servers, s)
}
m.mu.Unlock()
seen := map[string]struct{}{}
var out []string
for _, s := range servers {
s.mu.Lock()
for id := range s.lastFrom {
if _, dup := seen[id]; !dup {
seen[id] = struct{}{}
out = append(out, id)
}
}
s.mu.Unlock()
}
return out
}