fix(udp): send the structured QSO Logged too, not only the ADIF one
The ADIF datagram reached Logger32's socket and still nothing appeared in its log. WSJT-X sends TWO messages for every contact — QSO Logged (type 5), the structured one, and Logged ADIF (type 12) — and receivers differ on which they read: MacLoggerDX takes the structured one by default and offers the ADIF as an option. We were sending only the half Logger32 does not use. Both now go out on that row, in WSJT-X's own order. Nothing double-logs: any receiver built for WSJT-X already sees both from the real thing. Type 5 is read positionally, so the field order is pinned by a test against NetworkMessage.hpp — one field out of place shifts every one after it and the receiver files nonsense without complaining. QDateTime is Qt's own encoding: Julian day, milliseconds since midnight, then the time spec, which is sent as UTC (1) because a spec of 0 would have the receiver re-read the contact in its own zone. The Julian arithmetic is checked against known values; a day out there files every QSO on the wrong date.
This commit is contained in:
@@ -2779,7 +2779,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
if a.udp != nil {
|
if a.udp != nil {
|
||||||
rec := adif.SingleRecordADIF(qc)
|
rec := adif.SingleRecordADIF(qc)
|
||||||
a.udp.EmitLoggedADIF(rec)
|
a.udp.EmitLoggedADIF(rec)
|
||||||
a.udp.EmitLoggedADIFWSJT(rec)
|
a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
|
||||||
}
|
}
|
||||||
// Nudge the grid again so the award_refs columns appear once materialised.
|
// Nudge the grid again so the award_refs columns appear once materialised.
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
@@ -12207,7 +12207,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
if a.udp != nil {
|
if a.udp != nil {
|
||||||
rec := adif.SingleRecordADIF(qc)
|
rec := adif.SingleRecordADIF(qc)
|
||||||
a.udp.EmitLoggedADIF(rec)
|
a.udp.EmitLoggedADIF(rec)
|
||||||
a.udp.EmitLoggedADIFWSJT(rec)
|
a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec)
|
||||||
}
|
}
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "qso:logged", id) // refresh again so award_refs show
|
wruntime.EventsEmit(a.ctx, "qso:logged", id) // refresh again so award_refs show
|
||||||
@@ -18231,3 +18231,39 @@ func (a *App) ampTargets(id string, linked []string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wsjtLoggedQSO maps a logged contact onto the fields a WSJT-X "QSO Logged"
|
||||||
|
// message carries, for the outbound UDP row that speaks that protocol.
|
||||||
|
//
|
||||||
|
// TIME_OFF falls back to TIME_ON: the message has both, and a receiver that
|
||||||
|
// files the contact by the end time would put a QSO with no recorded end at the
|
||||||
|
// zero date — 1 January year 1, which is what a Julian day of zero means.
|
||||||
|
func wsjtLoggedQSO(q qso.QSO) udp.LoggedQSO {
|
||||||
|
out := udp.LoggedQSO{
|
||||||
|
DXCall: q.Callsign,
|
||||||
|
DXGrid: q.Grid,
|
||||||
|
Mode: q.Mode,
|
||||||
|
ReportSent: q.RSTSent,
|
||||||
|
ReportRcvd: q.RSTRcvd,
|
||||||
|
Comments: q.Comment,
|
||||||
|
Name: q.Name,
|
||||||
|
TimeOn: q.QSODate.UTC(),
|
||||||
|
TimeOff: q.QSODate.UTC(),
|
||||||
|
OperatorCall: q.Operator,
|
||||||
|
MyCall: q.StationCallsign,
|
||||||
|
MyGrid: q.MyGrid,
|
||||||
|
ExchangeSent: q.STXString,
|
||||||
|
ExchangeRcvd: q.SRXString,
|
||||||
|
PropMode: q.PropMode,
|
||||||
|
}
|
||||||
|
if !q.QSODateOff.IsZero() {
|
||||||
|
out.TimeOff = q.QSODateOff.UTC()
|
||||||
|
}
|
||||||
|
if q.FreqHz != nil && *q.FreqHz > 0 {
|
||||||
|
out.TxFreqHz = uint64(*q.FreqHz)
|
||||||
|
}
|
||||||
|
if q.TXPower != nil && *q.TXPower > 0 {
|
||||||
|
out.TxPower = strconv.FormatFloat(*q.TXPower, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@
|
|||||||
"QSL Manager, paper QSL: the route is set per direction — bureau, direct or electronic, beside the sent status and beside the received one — and applies to the whole selection at once.",
|
"QSL Manager, paper QSL: the route is set per direction — bureau, direct or electronic, beside the sent status and beside the received one — and applies to the whole selection at once.",
|
||||||
"Motorized antenna: when the controller settles somewhere other than where it was told, the log says so — that reads as “the antenna stopped responding” otherwise.",
|
"Motorized antenna: when the controller settles somewhere other than where it was told, the log says so — that reads as “the antenna stopped responding” otherwise.",
|
||||||
"UDP: an outbound message now logs that it was sent, and a logged QSO with no outbound ADIF row configured says so once.",
|
"UDP: an outbound message now logs that it was sent, and a logged QSO with no outbound ADIF row configured says so once.",
|
||||||
"UDP: new outbound “WSJT-X logged QSO” for Logger32 — its extra UDP sockets speak the WSJT-X protocol and silently ignore plain-text ADIF.",
|
"UDP: new outbound “WSJT-X logged QSO” for Logger32 and anything else on the WSJT-X interface. It sends both messages WSJT-X itself sends for a contact — the structured QSO Logged and the ADIF one — since receivers differ on which they read.",
|
||||||
"Appearance: the Sahara theme is lighter, parchment rather than deep sand, with a terracotta accent."
|
"Appearance: the Sahara theme is lighter, parchment rather than deep sand, with a terracotta accent."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"Gestionnaire QSL, QSL papier : la voie se règle par sens — bureau, direct ou électronique, à côté du statut envoyé et à côté du statut reçu — et s’applique à toute la sélection d’un coup.",
|
"Gestionnaire QSL, QSL papier : la voie se règle par sens — bureau, direct ou électronique, à côté du statut envoyé et à côté du statut reçu — et s’applique à toute la sélection d’un coup.",
|
||||||
"Antenne motorisée : quand le contrôleur se fixe ailleurs que là où on l’a envoyé, le journal le dit — sans quoi cela ressemble à une antenne qui ne répond plus.",
|
"Antenne motorisée : quand le contrôleur se fixe ailleurs que là où on l’a envoyé, le journal le dit — sans quoi cela ressemble à une antenne qui ne répond plus.",
|
||||||
"UDP : un message sortant inscrit désormais son envoi dans le journal, et un QSO enregistré sans ligne ADIF sortante le signale une fois.",
|
"UDP : un message sortant inscrit désormais son envoi dans le journal, et un QSO enregistré sans ligne ADIF sortante le signale une fois.",
|
||||||
"UDP : nouvelle sortie « QSO enregistré WSJT-X » pour Logger32 — ses sockets UDP supplémentaires parlent le protocole WSJT-X et ignorent l’ADIF en texte brut.",
|
"UDP : nouvelle sortie « QSO enregistré WSJT-X » pour Logger32 et tout ce qui parle l’interface WSJT-X. Elle envoie les deux messages que WSJT-X émet lui-même pour un contact — le QSO structuré et l’ADIF — car les récepteurs ne lisent pas le même.",
|
||||||
"Apparence : le thème Sahara s’éclaircit, parchemin plutôt que sable profond, avec un accent terre cuite."
|
"Apparence : le thème Sahara s’éclaircit, parchemin plutôt que sable profond, avec un accent terre cuite."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -103,15 +103,32 @@ func (m *Manager) EmitLoggedADIF(adif string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmitLoggedADIFWSJT sends the same record wrapped as a WSJT-X "Logged ADIF"
|
// EmitLoggedQSOWSJT announces a logged contact on the WSJT-X interface, for
|
||||||
// datagram, for receivers that speak that interface rather than plain text —
|
// receivers that speak it rather than plain text — Logger32's additional UDP
|
||||||
// Logger32's additional UDP sockets among them.
|
// sockets among them.
|
||||||
func (m *Manager) EmitLoggedADIFWSJT(adifRec string) {
|
//
|
||||||
if strings.TrimSpace(adifRec) == "" {
|
// BOTH messages go out, "QSO Logged" (5) then "Logged ADIF" (12), because that
|
||||||
|
// is exactly what WSJT-X does for every contact. Which one a receiver takes is
|
||||||
|
// its own business: MacLoggerDX reads the structured one by default and offers
|
||||||
|
// the ADIF as an option, and sending only the ADIF is why a QSO reached
|
||||||
|
// Logger32's socket and never reached its log. Anything built for WSJT-X
|
||||||
|
// already sees both from the real thing, so neither is a surprise and nothing
|
||||||
|
// logs the contact twice.
|
||||||
|
func (m *Manager) EmitLoggedQSOWSJT(q LoggedQSO, adifRec string) {
|
||||||
|
rows := m.Outbound(ServiceWSJTLog)
|
||||||
|
if len(rows) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, c := range m.Outbound(ServiceWSJTLog) {
|
qsoPkt := BuildWSJTQSOLogged("OpsLog", q)
|
||||||
m.sendTo(c, BuildWSJTLoggedADIF("OpsLog", adifRec))
|
var adifPkt []byte
|
||||||
|
if strings.TrimSpace(adifRec) != "" {
|
||||||
|
adifPkt = BuildWSJTLoggedADIF("OpsLog", adifRec)
|
||||||
|
}
|
||||||
|
for _, c := range rows {
|
||||||
|
m.sendTo(c, qsoPkt)
|
||||||
|
if adifPkt != nil {
|
||||||
|
m.sendTo(c, adifPkt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package udp
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sending WSJT-X UDP messages, as opposed to parsing them (wsjt.go).
|
// Sending WSJT-X UDP messages, as opposed to parsing them (wsjt.go).
|
||||||
@@ -48,3 +49,100 @@ func BuildWSJTLoggedADIF(id, adif string) []byte {
|
|||||||
b.Write(buildQString(adif))
|
b.Write(buildQString(adif))
|
||||||
return b.Bytes()
|
return b.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoggedQSO is what a WSJT-X "QSO Logged" message carries. Declared here rather
|
||||||
|
// than taking a qso.QSO so this package stays free of the logbook — it speaks a
|
||||||
|
// wire protocol and nothing else.
|
||||||
|
type LoggedQSO struct {
|
||||||
|
DXCall string
|
||||||
|
DXGrid string
|
||||||
|
TxFreqHz uint64
|
||||||
|
Mode string
|
||||||
|
ReportSent string
|
||||||
|
ReportRcvd string
|
||||||
|
TxPower string
|
||||||
|
Comments string
|
||||||
|
Name string
|
||||||
|
TimeOn time.Time // UTC
|
||||||
|
TimeOff time.Time // UTC
|
||||||
|
OperatorCall string
|
||||||
|
MyCall string
|
||||||
|
MyGrid string
|
||||||
|
ExchangeSent string
|
||||||
|
ExchangeRcvd string
|
||||||
|
PropMode string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildWSJTQSOLogged frames a WSJT-X "QSO Logged" datagram (message type 5).
|
||||||
|
//
|
||||||
|
// This is the message most loggers listen for — MacLoggerDX takes it by default
|
||||||
|
// and offers the ADIF one as an alternative, and WSJT-X itself sends BOTH for
|
||||||
|
// every contact. Sending only the ADIF form was the reason a QSO reached
|
||||||
|
// Logger32's socket and never reached its log.
|
||||||
|
//
|
||||||
|
// Field order is fixed by WSJT-X's NetworkMessage.hpp and cannot be rearranged:
|
||||||
|
// a receiver reads them positionally, so one field out of place shifts every
|
||||||
|
// one after it.
|
||||||
|
func BuildWSJTQSOLogged(id string, q LoggedQSO) []byte {
|
||||||
|
var b bytes.Buffer
|
||||||
|
var hdr [12]byte
|
||||||
|
binary.BigEndian.PutUint32(hdr[0:4], wsjtMagic)
|
||||||
|
binary.BigEndian.PutUint32(hdr[4:8], 2)
|
||||||
|
binary.BigEndian.PutUint32(hdr[8:12], wsjtMsgQSOLogged)
|
||||||
|
b.Write(hdr[:])
|
||||||
|
b.Write(buildQString(id))
|
||||||
|
b.Write(buildQDateTimeUTC(q.TimeOff))
|
||||||
|
b.Write(buildQString(q.DXCall))
|
||||||
|
b.Write(buildQString(q.DXGrid))
|
||||||
|
var f [8]byte
|
||||||
|
binary.BigEndian.PutUint64(f[:], q.TxFreqHz)
|
||||||
|
b.Write(f[:])
|
||||||
|
b.Write(buildQString(q.Mode))
|
||||||
|
b.Write(buildQString(q.ReportSent))
|
||||||
|
b.Write(buildQString(q.ReportRcvd))
|
||||||
|
b.Write(buildQString(q.TxPower))
|
||||||
|
b.Write(buildQString(q.Comments))
|
||||||
|
b.Write(buildQString(q.Name))
|
||||||
|
b.Write(buildQDateTimeUTC(q.TimeOn))
|
||||||
|
b.Write(buildQString(q.OperatorCall))
|
||||||
|
b.Write(buildQString(q.MyCall))
|
||||||
|
b.Write(buildQString(q.MyGrid))
|
||||||
|
b.Write(buildQString(q.ExchangeSent))
|
||||||
|
b.Write(buildQString(q.ExchangeRcvd))
|
||||||
|
b.Write(buildQString(q.PropMode))
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildQDateTimeUTC encodes a QDateTime the way QDataStream has since Qt 5:
|
||||||
|
//
|
||||||
|
// qint64 Julian day number
|
||||||
|
// quint32 milliseconds since midnight
|
||||||
|
// quint8 time spec — 1 is UTC, which is the only one we ever send
|
||||||
|
//
|
||||||
|
// Everything in the logbook is already UTC, and a receiver that guessed local
|
||||||
|
// time from a spec of 0 would file the contact in the wrong hour.
|
||||||
|
func buildQDateTimeUTC(t time.Time) []byte {
|
||||||
|
t = t.UTC()
|
||||||
|
out := make([]byte, 0, 13)
|
||||||
|
var jd [8]byte
|
||||||
|
binary.BigEndian.PutUint64(jd[:], uint64(julianDay(t)))
|
||||||
|
out = append(out, jd[:]...)
|
||||||
|
ms := uint32(t.Hour()*3600000 + t.Minute()*60000 + t.Second()*1000 + t.Nanosecond()/1e6)
|
||||||
|
var m [4]byte
|
||||||
|
binary.BigEndian.PutUint32(m[:], ms)
|
||||||
|
out = append(out, m[:]...)
|
||||||
|
return append(out, 1) // Qt::UTC
|
||||||
|
}
|
||||||
|
|
||||||
|
// julianDay is the standard Gregorian-to-JDN conversion. Integer division
|
||||||
|
// throughout — this is the arithmetic Qt uses, and a floating-point version
|
||||||
|
// lands a day out at the edges.
|
||||||
|
func julianDay(t time.Time) int64 {
|
||||||
|
y := int64(t.Year())
|
||||||
|
m := int64(t.Month())
|
||||||
|
d := int64(t.Day())
|
||||||
|
a := (14 - m) / 12
|
||||||
|
y2 := y + 4800 - a
|
||||||
|
m2 := m + 12*a - 3
|
||||||
|
return d + (153*m2+2)/5 + 365*y2 + y2/4 - y2/100 + y2/400 - 32045
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package udp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// What we send has to be what we can read back: Logger32 and the rest decode
|
// What we send has to be what we can read back: Logger32 and the rest decode
|
||||||
@@ -47,3 +49,91 @@ func TestWSJTLoggedADIFHeader(t *testing.T) {
|
|||||||
t.Errorf("packet = % X\nwant % X", pkt, want)
|
t.Errorf("packet = % X\nwant % X", pkt, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The julian day has to match Qt's, because the receiver turns it back into a
|
||||||
|
// date. A day out here files every contact on the wrong day.
|
||||||
|
func TestJulianDayMatchesKnownValues(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
date time.Time
|
||||||
|
want int64
|
||||||
|
}{
|
||||||
|
// Checked against the standard tables: JDN is the count at noon UTC.
|
||||||
|
{time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), 2451545},
|
||||||
|
{time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC), 2440588},
|
||||||
|
{time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC), 2461268},
|
||||||
|
} {
|
||||||
|
if got := julianDay(c.date); got != c.want {
|
||||||
|
t.Errorf("julianDay(%s) = %d, want %d", c.date.Format("2006-01-02"), got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QDateTime is julian day, then milliseconds since midnight, then the time
|
||||||
|
// spec. A receiver reads them positionally, and a spec of 0 (local time) would
|
||||||
|
// have it re-interpret a UTC contact in its own zone.
|
||||||
|
func TestQDateTimeIsUTCAndPositional(t *testing.T) {
|
||||||
|
got := buildQDateTimeUTC(time.Date(2026, 8, 15, 17, 44, 33, 0, time.UTC))
|
||||||
|
if len(got) != 13 {
|
||||||
|
t.Fatalf("%d bytes, want 13", len(got))
|
||||||
|
}
|
||||||
|
if jd := int64(binary.BigEndian.Uint64(got[0:8])); jd != 2461268 {
|
||||||
|
t.Errorf("julian day = %d, want 2461268", jd)
|
||||||
|
}
|
||||||
|
wantMs := uint32((17*3600 + 44*60 + 33) * 1000)
|
||||||
|
if ms := binary.BigEndian.Uint32(got[8:12]); ms != wantMs {
|
||||||
|
t.Errorf("ms since midnight = %d, want %d", ms, wantMs)
|
||||||
|
}
|
||||||
|
if got[12] != 1 {
|
||||||
|
t.Errorf("time spec = %d, want 1 (UTC)", got[12])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The QSO Logged message is read positionally: one field out of place shifts
|
||||||
|
// every one after it, and the receiver silently files nonsense. This pins the
|
||||||
|
// order against WSJT-X's NetworkMessage.hpp.
|
||||||
|
func TestWSJTQSOLoggedFieldOrder(t *testing.T) {
|
||||||
|
pkt := BuildWSJTQSOLogged("OpsLog", LoggedQSO{
|
||||||
|
DXCall: "VK9XX", DXGrid: "QH30", TxFreqHz: 14074000, Mode: "FT8",
|
||||||
|
ReportSent: "-12", ReportRcvd: "-08", TxPower: "50", Comments: "tnx",
|
||||||
|
Name: "Ken", TimeOn: time.Date(2026, 8, 15, 17, 44, 0, 0, time.UTC),
|
||||||
|
TimeOff: time.Date(2026, 8, 15, 17, 45, 0, 0, time.UTC),
|
||||||
|
MyCall: "F4BPO", MyGrid: "IN95", OperatorCall: "F4BPO",
|
||||||
|
PropMode: "",
|
||||||
|
})
|
||||||
|
r := bytes.NewReader(pkt)
|
||||||
|
var magic, schema, mtype uint32
|
||||||
|
binary.Read(r, binary.BigEndian, &magic)
|
||||||
|
binary.Read(r, binary.BigEndian, &schema)
|
||||||
|
binary.Read(r, binary.BigEndian, &mtype)
|
||||||
|
if magic != wsjtMagic || schema != 2 || mtype != wsjtMsgQSOLogged {
|
||||||
|
t.Fatalf("header magic=%08x schema=%d type=%d", magic, schema, mtype)
|
||||||
|
}
|
||||||
|
id, _ := readQString(r)
|
||||||
|
if id != "OpsLog" {
|
||||||
|
t.Errorf("id = %q", id)
|
||||||
|
}
|
||||||
|
r.Seek(13, 1) // QSO off
|
||||||
|
call, _ := readQString(r)
|
||||||
|
grid, _ := readQString(r)
|
||||||
|
var freq uint64
|
||||||
|
binary.Read(r, binary.BigEndian, &freq)
|
||||||
|
mode, _ := readQString(r)
|
||||||
|
sent, _ := readQString(r)
|
||||||
|
rcvd, _ := readQString(r)
|
||||||
|
if call != "VK9XX" || grid != "QH30" || freq != 14074000 || mode != "FT8" || sent != "-12" || rcvd != "-08" {
|
||||||
|
t.Errorf("got call=%q grid=%q freq=%d mode=%q sent=%q rcvd=%q", call, grid, freq, mode, sent, rcvd)
|
||||||
|
}
|
||||||
|
pwr, _ := readQString(r)
|
||||||
|
comments, _ := readQString(r)
|
||||||
|
name, _ := readQString(r)
|
||||||
|
if pwr != "50" || comments != "tnx" || name != "Ken" {
|
||||||
|
t.Errorf("got pwr=%q comments=%q name=%q", pwr, comments, name)
|
||||||
|
}
|
||||||
|
r.Seek(13, 1) // QSO on
|
||||||
|
op, _ := readQString(r)
|
||||||
|
myCall, _ := readQString(r)
|
||||||
|
myGrid, _ := readQString(r)
|
||||||
|
if op != "F4BPO" || myCall != "F4BPO" || myGrid != "IN95" {
|
||||||
|
t.Errorf("got op=%q myCall=%q myGrid=%q", op, myCall, myGrid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user