chore: release v0.19.4
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user