chore: release v0.19.4
This commit is contained in:
+6
-5
@@ -205,11 +205,12 @@ func (m *Manager) SetPTT(on bool) error {
|
||||
// the backend picks a default when it's empty. (Status-based colouring can be
|
||||
// driven later by setting Color per spot.)
|
||||
type SpotInfo struct {
|
||||
FreqHz int64
|
||||
Callsign string
|
||||
Mode string
|
||||
Color string
|
||||
Comment string
|
||||
FreqHz int64
|
||||
Callsign string
|
||||
Mode string
|
||||
Color string
|
||||
Comment string
|
||||
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
|
||||
}
|
||||
|
||||
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
||||
|
||||
+21
-3
@@ -60,6 +60,7 @@ type Flex struct {
|
||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||
|
||||
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||
@@ -157,7 +158,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
||||
return &Flex{
|
||||
host: strings.TrimSpace(host), port: port,
|
||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, pendingSplit: map[int]bool{},
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||
}
|
||||
@@ -352,6 +353,7 @@ func (f *Flex) reader(conn net.Conn) {
|
||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||
f.spotCall[idx] = call
|
||||
f.spotIdx[idx] = true
|
||||
f.spotByCall[strings.ToUpper(call)] = idx
|
||||
}
|
||||
}
|
||||
// A successful "slice create" for split returns the new slice's index;
|
||||
@@ -1071,8 +1073,23 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
||||
if color == "" {
|
||||
color = "#FFFFA500" // opaque orange default
|
||||
}
|
||||
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=1800 trigger_action=Tune timestamp=%d",
|
||||
float64(s.FreqHz)/1e6, call, color, time.Now().Unix())
|
||||
life := s.LifetimeSec
|
||||
if life <= 0 {
|
||||
life = 1800 // default 30 min (cluster spots)
|
||||
}
|
||||
// De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already
|
||||
// spotted gets its previous spot removed first — one live spot per call,
|
||||
// refreshed, instead of a pile that all expire independently.
|
||||
f.mu.Lock()
|
||||
if old, ok := f.spotByCall[strings.ToUpper(s.Callsign)]; ok {
|
||||
delete(f.spotByCall, strings.ToUpper(s.Callsign))
|
||||
delete(f.spotCall, old)
|
||||
delete(f.spotIdx, old)
|
||||
f.send(fmt.Sprintf("spot remove %d", old))
|
||||
}
|
||||
f.mu.Unlock()
|
||||
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
|
||||
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
||||
if m := flexEncode(s.Mode); m != "" {
|
||||
cmd += " mode=" + m
|
||||
}
|
||||
@@ -1118,6 +1135,7 @@ func (f *Flex) ClearSpots() error {
|
||||
f.mu.Lock()
|
||||
f.spotIdx = map[int]bool{}
|
||||
f.spotCall = map[int]string{}
|
||||
f.spotByCall = map[string]int{}
|
||||
connected := f.conn != nil
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
|
||||
@@ -43,9 +43,15 @@ type Event struct {
|
||||
|
||||
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
|
||||
DXGrid string // ServiceWSJT (Status)
|
||||
Mode string // ServiceWSJT (Status)
|
||||
Mode string // ServiceWSJT (Status/Decode)
|
||||
FreqHz int64 // ServiceWSJT (Status)
|
||||
LoggedADIF string // ServiceWSJT (LoggedADIF), ServiceADIF or ServiceN1MM
|
||||
|
||||
// A WSJT-X Decode (heard station) to render on the panadapter.
|
||||
DecodeCall string // transmitting (DE) callsign
|
||||
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
||||
DecodeSNR int // reported SNR (dB)
|
||||
DecodeCQ bool // the decode was a CQ
|
||||
}
|
||||
|
||||
// Server is a single inbound UDP listener.
|
||||
@@ -57,6 +63,8 @@ type Server struct {
|
||||
done chan struct{}
|
||||
stopped bool
|
||||
mu sync.Mutex
|
||||
|
||||
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
||||
}
|
||||
|
||||
func newServer(cfg Config, out chan<- Event) *Server {
|
||||
@@ -175,9 +183,29 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
applog.Printf("udp: [%s] WSJT msg type ignored\n", s.cfg.Name)
|
||||
return
|
||||
}
|
||||
// Status carries the current dial frequency; remember it so Decode audio
|
||||
// offsets can be turned into RF frequencies for the panadapter.
|
||||
if w.FreqHz > 0 && !w.IsDecode {
|
||||
s.mu.Lock()
|
||||
s.lastDialHz = w.FreqHz
|
||||
s.mu.Unlock()
|
||||
}
|
||||
if w.IsDecode {
|
||||
s.mu.Lock()
|
||||
dial := s.lastDialHz
|
||||
s.mu.Unlock()
|
||||
if dial <= 0 {
|
||||
return // no dial freq yet (Status not seen) → can't place the spot
|
||||
}
|
||||
ev.DecodeCall = w.DecodeCall
|
||||
ev.DecodeFreqHz = dial + w.DeltaFreqHz
|
||||
ev.DecodeSNR = w.SNR
|
||||
ev.DecodeCQ = w.IsCQ
|
||||
ev.Mode = w.Mode
|
||||
break
|
||||
}
|
||||
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||
ev.DXCall = w.DXCall
|
||||
@@ -241,7 +269,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
return
|
||||
}
|
||||
// Empty events are useless; skip.
|
||||
if ev.DXCall == "" && ev.LoggedADIF == "" {
|
||||
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" {
|
||||
return
|
||||
}
|
||||
select {
|
||||
|
||||
@@ -37,18 +37,27 @@ const (
|
||||
)
|
||||
|
||||
// WSJTEvent is the parsed, typed result of decoding a single packet.
|
||||
// One of (DXCall, LoggedADIF) is non-empty depending on the message.
|
||||
// One of (DXCall, LoggedADIF, DecodeCall) is non-empty depending on the message.
|
||||
type WSJTEvent struct {
|
||||
DXCall string // current "DX Call" field in the WSJT app
|
||||
DXGrid string // optional grid for that call
|
||||
DXCall string // current "DX Call" field in the WSJT app (Status)
|
||||
DXGrid string // optional grid for that call (Status)
|
||||
Mode string // FT8 / FT4 / …
|
||||
FreqHz int64 // current dial freq when available
|
||||
FreqHz int64 // current dial freq when available (Status)
|
||||
LoggedADIF string // full ADIF text when message is LoggedADIF
|
||||
ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup
|
||||
|
||||
// Decode (type 2): the transmitting station heard on the band. FreqHz is NOT
|
||||
// set here (Decode carries only the audio offset); the caller adds the last
|
||||
// known dial frequency (from Status) to DeltaFreqHz to get the RF frequency.
|
||||
IsDecode bool
|
||||
DecodeCall string // the sender (DE) callsign extracted from the message text
|
||||
DeltaFreqHz int64 // audio offset within the passband (Hz)
|
||||
SNR int // reported signal-to-noise (dB)
|
||||
IsCQ bool // the decode was a CQ call
|
||||
}
|
||||
|
||||
// ParseWSJT decodes one UDP packet. Returns ok=false for messages we
|
||||
// don't care about (heartbeat, decode lines, clears, etc.).
|
||||
// don't care about (heartbeat, clears, etc.).
|
||||
func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||
if len(pkt) < 12 {
|
||||
return WSJTEvent{}, false, fmt.Errorf("packet too short")
|
||||
@@ -143,6 +152,56 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||
ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))
|
||||
return ev, true, nil
|
||||
|
||||
case wsjtMsgDecode:
|
||||
// Decode payload (v2):
|
||||
// bool is_new
|
||||
// quint32 time (ms since midnight)
|
||||
// qint32 snr
|
||||
// double delta_time (seconds)
|
||||
// quint32 delta_frequency (Hz, audio offset in the passband)
|
||||
// QUtf8 mode
|
||||
// QUtf8 message (the decoded text, e.g. "CQ K1ABC FN42")
|
||||
// bool low_confidence
|
||||
// bool off_air
|
||||
var b uint8
|
||||
if err := binary.Read(r, binary.BigEndian, &b); err != nil { // is_new
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
var t32, df uint32
|
||||
var snr int32
|
||||
if err := binary.Read(r, binary.BigEndian, &t32); err != nil { // time
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
if err := binary.Read(r, binary.BigEndian, &snr); err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
var dt float64
|
||||
if err := binary.Read(r, binary.BigEndian, &dt); err != nil { // delta_time
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
if err := binary.Read(r, binary.BigEndian, &df); err != nil { // delta_frequency
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
mode, err := readQString(r)
|
||||
if err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
msg, err := readQString(r)
|
||||
if err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
call, isCQ := wsjtSender(msg)
|
||||
if call == "" {
|
||||
return WSJTEvent{}, false, nil // free-text / telemetry / unparseable → ignore
|
||||
}
|
||||
ev.IsDecode = true
|
||||
ev.DecodeCall = call
|
||||
ev.IsCQ = isCQ
|
||||
ev.DeltaFreqHz = int64(df)
|
||||
ev.SNR = int(snr)
|
||||
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||
return ev, true, nil
|
||||
|
||||
case wsjtMsgLoggedADIF:
|
||||
// Payload: a single QString containing the ADIF record.
|
||||
adif, err := readQString(r)
|
||||
@@ -155,6 +214,64 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||
return WSJTEvent{}, false, nil
|
||||
}
|
||||
|
||||
// wsjtSender extracts the transmitting (DE) callsign from a WSJT-X message and
|
||||
// whether it was a CQ. Grammar:
|
||||
//
|
||||
// CQ [modifier] <de_call> [grid] → de_call, isCQ=true
|
||||
// <to_call> <de_call> [report|…] → de_call, isCQ=false
|
||||
//
|
||||
// Returns "" for free-text / telemetry / hashed-call messages we can't resolve.
|
||||
func wsjtSender(message string) (call string, isCQ bool) {
|
||||
f := strings.Fields(strings.ToUpper(strings.TrimSpace(message)))
|
||||
if len(f) == 0 {
|
||||
return "", false
|
||||
}
|
||||
if f[0] == "CQ" {
|
||||
// Skip an optional modifier after CQ (DX / a region like NA / a zone like
|
||||
// 020) — it never looks like a callsign (no letter+digit mix).
|
||||
idx := 1
|
||||
if len(f) > 2 && !looksLikeCall(f[1]) {
|
||||
idx = 2
|
||||
}
|
||||
if idx < len(f) && looksLikeCall(f[idx]) {
|
||||
return f[idx], true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
// Standard exchange: the DE (sender) call is the second token.
|
||||
if len(f) >= 2 && looksLikeCall(f[1]) {
|
||||
return f[1], false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// looksLikeCall is a loose callsign test: 3–12 chars of A–Z/0–9//, with at least
|
||||
// one letter AND one digit. Rejects the fixed exchange tokens (RR73/RRR/73) that
|
||||
// would otherwise pass.
|
||||
func looksLikeCall(s string) bool {
|
||||
switch s {
|
||||
case "RR73", "RRR", "73":
|
||||
return false
|
||||
}
|
||||
if len(s) < 3 || len(s) > 12 {
|
||||
return false
|
||||
}
|
||||
hasL, hasD := false, false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z':
|
||||
hasL = true
|
||||
case c >= '0' && c <= '9':
|
||||
hasD = true
|
||||
case c == '/':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasL && hasD
|
||||
}
|
||||
|
||||
// readQString reads a Qt QString as written by QDataStream: an int32 byte
|
||||
// length (or -1 for null) followed by the UTF-8 bytes.
|
||||
func readQString(r *bytes.Reader) (string, error) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package udp
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWSJTSender(t *testing.T) {
|
||||
cases := []struct {
|
||||
msg string
|
||||
wantCall string
|
||||
wantCQ bool
|
||||
}{
|
||||
{"CQ K1ABC FN42", "K1ABC", true},
|
||||
{"CQ DX W2XYZ EM12", "W2XYZ", true}, // modifier "DX" skipped
|
||||
{"CQ NA VE3ABC FN03", "VE3ABC", true}, // region modifier skipped
|
||||
{"CQ 020 JA1XYZ PM95", "JA1XYZ", true},// zone modifier skipped
|
||||
{"W2XYZ K1ABC -10", "K1ABC", false}, // exchange → sender is 2nd call
|
||||
{"W2XYZ K1ABC R-10", "K1ABC", false},
|
||||
{"W2XYZ K1ABC RR73", "K1ABC", false},
|
||||
{"F4BPO K1ABC/P 73", "K1ABC/P", false},// portable call kept
|
||||
{"CQ F/DL1ABC JO31", "F/DL1ABC", true},// compound prefix
|
||||
// Non-callsign / free text → no sender.
|
||||
{"TNX 73 GL", "", false},
|
||||
{"K1ABC RR73", "", false}, // only one call + a token → 2nd token not a call
|
||||
{"", "", false},
|
||||
{"CQ CQ CQ", "", true}, // CQ but no resolvable call
|
||||
}
|
||||
for _, c := range cases {
|
||||
call, cq := wsjtSender(c.msg)
|
||||
if call != c.wantCall || cq != c.wantCQ {
|
||||
t.Errorf("wsjtSender(%q) = (%q,%v), want (%q,%v)", c.msg, call, cq, c.wantCall, c.wantCQ)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user