diff --git a/app.go b/app.go index 64331f3..cfc6b14 100644 --- a/app.go +++ b/app.go @@ -2779,7 +2779,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { if a.udp != nil { rec := adif.SingleRecordADIF(qc) 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. if a.ctx != nil { @@ -12207,7 +12207,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) { if a.udp != nil { rec := adif.SingleRecordADIF(qc) a.udp.EmitLoggedADIF(rec) - a.udp.EmitLoggedADIFWSJT(rec) + a.udp.EmitLoggedQSOWSJT(wsjtLoggedQSO(qc), rec) } if a.ctx != nil { 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 } + +// 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 +} diff --git a/changelog.json b/changelog.json index e9915f6..f1d5896 100644 --- a/changelog.json +++ b/changelog.json @@ -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.", "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: 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." ], "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.", "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 : 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." ] }, diff --git a/internal/integrations/udp/outbound.go b/internal/integrations/udp/outbound.go index 4a2cff0..35d884e 100644 --- a/internal/integrations/udp/outbound.go +++ b/internal/integrations/udp/outbound.go @@ -103,15 +103,32 @@ func (m *Manager) EmitLoggedADIF(adif string) { } } -// EmitLoggedADIFWSJT sends the same record wrapped as a WSJT-X "Logged ADIF" -// datagram, for receivers that speak that interface rather than plain text — -// Logger32's additional UDP sockets among them. -func (m *Manager) EmitLoggedADIFWSJT(adifRec string) { - if strings.TrimSpace(adifRec) == "" { +// EmitLoggedQSOWSJT announces a logged contact on the WSJT-X interface, for +// receivers that speak it rather than plain text — Logger32's additional UDP +// sockets among them. +// +// 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 } - for _, c := range m.Outbound(ServiceWSJTLog) { - m.sendTo(c, BuildWSJTLoggedADIF("OpsLog", adifRec)) + qsoPkt := BuildWSJTQSOLogged("OpsLog", q) + 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) + } } } diff --git a/internal/integrations/udp/wsjtsend.go b/internal/integrations/udp/wsjtsend.go index bdce781..e1f5ca8 100644 --- a/internal/integrations/udp/wsjtsend.go +++ b/internal/integrations/udp/wsjtsend.go @@ -3,6 +3,7 @@ package udp import ( "bytes" "encoding/binary" + "time" ) // 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)) 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 +} diff --git a/internal/integrations/udp/wsjtsend_test.go b/internal/integrations/udp/wsjtsend_test.go index f9deb77..1722f33 100644 --- a/internal/integrations/udp/wsjtsend_test.go +++ b/internal/integrations/udp/wsjtsend_test.go @@ -2,7 +2,9 @@ package udp import ( "bytes" + "encoding/binary" "testing" + "time" ) // 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) } } + +// 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) + } +}