chore: release v0.26.0
This commit is contained in:
@@ -37,6 +37,19 @@ const (
|
||||
ServiceDBUpdated ServiceType = "db_updated" // ADIF of each locally-logged QSO (on save)
|
||||
ServicePstFreq ServiceType = "pstrotator_freq" // <PST><FREQUENCY> radio freq (on freq change)
|
||||
ServiceN1MMRadio ServiceType = "n1mm_radioinfo" // N1MM RadioInfo XML: freq+mode (on freq/mode change)
|
||||
// ServiceWSJTRelay re-sends every datagram an inbound WSJT listener receives,
|
||||
// byte for byte, to somewhere else.
|
||||
//
|
||||
// WSJT-X, JTDX and MSHV send to ONE address. Anything else that wants the
|
||||
// same stream — JTAlert, GridTracker, a second logger — has to be fed by a
|
||||
// relay, and this is it, so OpsLog stops being the reason you cannot run the
|
||||
// two together.
|
||||
//
|
||||
// VERBATIM, with no origin header prepended. The relays in the field add one
|
||||
// ("127.0.0.1:2237|"), which is precisely why stripForwarderHeader exists on
|
||||
// the receiving side here; inflicting the same thing on whatever is
|
||||
// downstream would be repeating a mistake we had to write code to survive.
|
||||
ServiceWSJTRelay ServiceType = "wsjt_relay"
|
||||
// ServiceWSJTLog wraps the same ADIF in a WSJT-X "Logged ADIF" datagram.
|
||||
// Logger32 and others listen on the WSJT-X interface, not for plain text, and
|
||||
// discard a bare ADIF record without a word — so the two cannot be one row.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A grid reaches OpsLog in TWO kinds of message, not one. The reply that
|
||||
// answers a CQ with a locator — "C91RU IZ5EME JN52" — is the commonest of them
|
||||
// on a busy band, and reading only CQs meant any station whose CQ we happened
|
||||
// to miss had no known grid at all, so it could never be flagged as a new one.
|
||||
func TestDecodeGridFromBothMessageShapes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
msg string
|
||||
call string
|
||||
cq bool
|
||||
grid string
|
||||
why string
|
||||
}{
|
||||
{"CQ RW9MZ MO74", "RW9MZ", true, "MO74", "a plain CQ"},
|
||||
{"CQ DX F4BPO JN36", "F4BPO", true, "JN36", "a CQ with a modifier"},
|
||||
{"C91RU IZ5EME JN52", "IZ5EME", false, "JN52", "a reply answering with its grid"},
|
||||
{"YB1EWD YO6GLT KN36", "YO6GLT", false, "KN36", "the same, other way round"},
|
||||
|
||||
// Everything else in the third slot is a report or a sign-off, and must
|
||||
// never be mistaken for a locator.
|
||||
{"C91RU IZ5EME -05", "IZ5EME", false, "", "a signal report is not a grid"},
|
||||
{"C91RU IZ5EME R-05", "IZ5EME", false, "", "a rogered report is not a grid"},
|
||||
{"C91RU IZ5EME RRR", "IZ5EME", false, "", "RRR is not a grid"},
|
||||
{"C91RU IZ5EME RR73", "IZ5EME", false, "", "RR73 has the shape of one and is not"},
|
||||
{"C91RU IZ5EME 73", "IZ5EME", false, "", "73 is not a grid"},
|
||||
{"<YO7LBX> 8F81SU RR73", "8F81SU", false, "", "a hashed call, signing off"},
|
||||
} {
|
||||
call, cq, grid := wsjtSender(tc.msg)
|
||||
if call != tc.call || cq != tc.cq || grid != tc.grid {
|
||||
t.Errorf("wsjtSender(%q) = (%q, %v, %q), want (%q, %v, %q) — %s",
|
||||
tc.msg, call, cq, grid, tc.call, tc.cq, tc.grid, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Reply replays the decode field for field, so the message must survive the
|
||||
// trip UNTRIMMED. DecodeMsg is trimmed for display; DecodeMsgRaw is what goes
|
||||
// back, and a stripped trailing space is a mismatch the far end reports nowhere.
|
||||
func TestDecodeKeepsTheUntrimmedMessage(t *testing.T) {
|
||||
qstr := func(b *bytes.Buffer, v string) {
|
||||
binary.Write(b, binary.BigEndian, int32(len(v)))
|
||||
b.WriteString(v)
|
||||
}
|
||||
const padded = "CQ RW9MZ MO74 "
|
||||
var p bytes.Buffer
|
||||
binary.Write(&p, binary.BigEndian, uint32(wsjtMagic))
|
||||
binary.Write(&p, binary.BigEndian, uint32(2))
|
||||
binary.Write(&p, binary.BigEndian, uint32(wsjtMsgDecode))
|
||||
qstr(&p, "JTDX")
|
||||
binary.Write(&p, binary.BigEndian, uint8(1))
|
||||
binary.Write(&p, binary.BigEndian, uint32(45_000_000))
|
||||
binary.Write(&p, binary.BigEndian, int32(-6))
|
||||
binary.Write(&p, binary.BigEndian, float64(0.2))
|
||||
binary.Write(&p, binary.BigEndian, uint32(1500))
|
||||
qstr(&p, "~")
|
||||
qstr(&p, padded)
|
||||
|
||||
ev, ok, err := ParseWSJT(p.Bytes())
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("parse: %v ok=%v", err, ok)
|
||||
}
|
||||
if ev.DecodeMsgRaw != padded {
|
||||
t.Errorf("DecodeMsgRaw = %q, want %q — a Reply built from this will not match", ev.DecodeMsgRaw, padded)
|
||||
}
|
||||
if ev.DecodeMsg != "CQ RW9MZ MO74" {
|
||||
t.Errorf("DecodeMsg = %q, want it trimmed for display", ev.DecodeMsg)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package udp
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A Decode does not carry the mode's NAME. It carries the one-character marker
|
||||
// from the decode line — "~" for FT8, "+" for FT4 — and that character used to
|
||||
@@ -57,3 +62,54 @@ func upper(s string) string {
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Resolving the marker must not DESTROY it. A Reply is matched by the receiving
|
||||
// application against its own decode list field for field, and the mode field it
|
||||
// compares is the one it sent — the marker. Sending the resolved name instead is
|
||||
// accepted silently and simply never transmits: JTDX finds no matching decode,
|
||||
// and no error is reported anywhere to say so. Reported as "clicking a CQ does
|
||||
// nothing in JTDX".
|
||||
//
|
||||
// So a Decode has to carry BOTH: Mode for the log and the status resolver,
|
||||
// DecodeModeRaw for the Reply.
|
||||
func TestDecodeKeepsTheRawModeMarkerForReplies(t *testing.T) {
|
||||
qstr := func(b *bytes.Buffer, v string) {
|
||||
binary.Write(b, binary.BigEndian, int32(len(v)))
|
||||
b.WriteString(v)
|
||||
}
|
||||
var p bytes.Buffer
|
||||
binary.Write(&p, binary.BigEndian, uint32(wsjtMagic))
|
||||
binary.Write(&p, binary.BigEndian, uint32(2)) // schema
|
||||
binary.Write(&p, binary.BigEndian, uint32(wsjtMsgDecode)) // type 2
|
||||
qstr(&p, "JTDX") // id
|
||||
binary.Write(&p, binary.BigEndian, uint8(1)) // is_new
|
||||
binary.Write(&p, binary.BigEndian, uint32(45_000_000)) // time (ms since midnight)
|
||||
binary.Write(&p, binary.BigEndian, int32(-6)) // snr
|
||||
binary.Write(&p, binary.BigEndian, float64(0.2)) // delta_time
|
||||
binary.Write(&p, binary.BigEndian, uint32(1500)) // delta_frequency
|
||||
qstr(&p, "~") // mode — the MARKER
|
||||
qstr(&p, "CQ EN35UKR") // message
|
||||
|
||||
out := make(chan Event, 4)
|
||||
s := &Server{
|
||||
out: out,
|
||||
cfg: Config{Name: "JTDX", ServiceType: ServiceWSJT},
|
||||
lastMode: map[string]string{"JTDX": "FT8"},
|
||||
dialHz: map[string]int64{"JTDX": 14074000},
|
||||
}
|
||||
s.handle(p.Bytes(), &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2237})
|
||||
|
||||
var ev Event
|
||||
select {
|
||||
case ev = <-out:
|
||||
default:
|
||||
t.Fatal("no event emitted for the decode")
|
||||
}
|
||||
if ev.Mode != "FT8" {
|
||||
t.Errorf("resolved Mode = %q, want FT8 (what the log and the status resolver need)", ev.Mode)
|
||||
}
|
||||
if ev.DecodeModeRaw != "~" {
|
||||
t.Errorf("DecodeModeRaw = %q, want %q — a Reply built from this will not match and JTDX will not transmit",
|
||||
ev.DecodeModeRaw, "~")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,31 +5,50 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const ms = 1000
|
||||
|
||||
func sec(h, m, s int) uint32 { return uint32((h*3600 + m*60 + s) * ms) }
|
||||
|
||||
// WSJT-X stamps a decode with a time of DAY and no date, so the date has to come
|
||||
// from our own clock — and around midnight the two disagree. A decode stamped
|
||||
// 23:59:58 that reaches us at 00:00:01 would be dated the NEW day, putting it
|
||||
// almost 24 hours in the future: it would sort to the top of the decodes panel
|
||||
// and stay there for the rest of the session, and its period would never line up
|
||||
// with the ones around it.
|
||||
//
|
||||
// The clock is passed IN. The previous version of this test read the real one
|
||||
// and so failed every afternoon — a 23:59:58 stamp seen at 17:00 is genuinely
|
||||
// hours in the future, and no rule can make it otherwise. A suite that is red
|
||||
// for half the day is a suite nobody reads.
|
||||
func TestDecodeTimeCrossesMidnight(t *testing.T) {
|
||||
const ms = 1000
|
||||
sec := func(h, m, s int) uint32 { return uint32((h*3600 + m*60 + s) * ms) }
|
||||
|
||||
got := decodeTime(sec(23, 59, 58))
|
||||
now := time.Now().UTC()
|
||||
// Whatever the clock says, a decode must never land in the future beyond the
|
||||
// slack of a single period, nor more than a day in the past.
|
||||
if d := got.Sub(now); d > time.Minute {
|
||||
t.Errorf("decode at 23:59:58 resolved to %s, %s in the FUTURE", got.Format(time.RFC3339), d)
|
||||
// Just after midnight, a stamp from the last seconds of yesterday.
|
||||
now := time.Date(2026, 3, 14, 0, 0, 1, 0, time.UTC)
|
||||
got := decodeTimeAt(sec(23, 59, 58), now)
|
||||
if want := time.Date(2026, 3, 13, 23, 59, 58, 0, time.UTC); !got.Equal(want) {
|
||||
t.Errorf("23:59:58 seen at 00:00:01 → %s, want %s (yesterday)", got, want)
|
||||
}
|
||||
if d := now.Sub(got); d > 24*time.Hour {
|
||||
t.Errorf("decode at 23:59:58 resolved to %s, %s in the past", got.Format(time.RFC3339), d)
|
||||
if got.After(now) {
|
||||
t.Errorf("%s is in the future — it would sort to the top of the list for the session", got)
|
||||
}
|
||||
}
|
||||
|
||||
// And the ordinary case: a stamp close to now stays on today.
|
||||
near := decodeTime(sec(now.Hour(), now.Minute(), now.Second()))
|
||||
if diff := near.Sub(now); diff > 2*time.Second || diff < -2*time.Second {
|
||||
t.Errorf("a decode stamped at the current time resolved to %s (%s off)", near.Format(time.RFC3339), diff)
|
||||
// The mirror case: just before midnight, a stamp from the first seconds of
|
||||
// tomorrow. Our clock is still on the old day.
|
||||
func TestDecodeTimeCrossesMidnightBackwards(t *testing.T) {
|
||||
now := time.Date(2026, 3, 13, 23, 59, 58, 0, time.UTC)
|
||||
got := decodeTimeAt(sec(0, 0, 1), now)
|
||||
if want := time.Date(2026, 3, 14, 0, 0, 1, 0, time.UTC); !got.Equal(want) {
|
||||
t.Errorf("00:00:01 seen at 23:59:58 → %s, want %s (tomorrow)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary case, which is every decode that is not within a few seconds of
|
||||
// midnight: the stamp belongs to today and is left alone.
|
||||
func TestDecodeTimeOrdinary(t *testing.T) {
|
||||
now := time.Date(2026, 3, 13, 14, 30, 0, 0, time.UTC)
|
||||
got := decodeTimeAt(sec(14, 29, 45), now)
|
||||
if want := time.Date(2026, 3, 13, 14, 29, 45, 0, time.UTC); !got.Equal(want) {
|
||||
t.Errorf("a stamp close to now → %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,16 +56,15 @@ func TestDecodeTimeCrossesMidnight(t *testing.T) {
|
||||
// fifteen-second slot must floor to the same period however far apart in the
|
||||
// slot they were heard.
|
||||
func TestDecodesInOneSlotShareAPeriod(t *testing.T) {
|
||||
const ms = 1000
|
||||
at := func(h, m, s int) time.Time { return decodeTime(uint32((h*3600 + m*60 + s) * ms)) }
|
||||
now := time.Date(2026, 3, 13, 12, 30, 7, 0, time.UTC)
|
||||
at := func(h, m, s int) time.Time { return decodeTimeAt(sec(h, m, s), now) }
|
||||
floor := func(x time.Time) int64 { return x.Unix() / 15 * 15 }
|
||||
|
||||
a, b := at(12, 30, 0), at(12, 30, 14)
|
||||
if floor(a) != floor(b) {
|
||||
t.Errorf("12:30:00 and 12:30:14 fell in different periods (%d vs %d)", floor(a), floor(b))
|
||||
t.Errorf("%s and %s fell in different periods", a, b)
|
||||
}
|
||||
c := at(12, 30, 15)
|
||||
if floor(a) == floor(c) {
|
||||
t.Error("12:30:00 and 12:30:15 shared a period — the slot boundary was not honoured")
|
||||
if floor(at(12, 30, 14)) == floor(at(12, 30, 15)) {
|
||||
t.Error("12:30:14 and 12:30:15 are different slots and must not share a period")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package udp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
@@ -185,3 +186,94 @@ func (m *Manager) sendTo(c Config, payload []byte) {
|
||||
applog.Printf("udp: [%s] sent %d bytes to %s (%s)%s",
|
||||
c.Name, len(payload), dst, c.ServiceType, sentPreview(c, payload))
|
||||
}
|
||||
|
||||
// RelayInbound re-sends one received datagram, unchanged, to every configured
|
||||
// relay destination.
|
||||
//
|
||||
// Called with the RAW bytes as they arrived — before parsing, and whatever the
|
||||
// parse made of them. A packet this build cannot decode is still a packet the
|
||||
// application downstream may understand perfectly well, and a relay that only
|
||||
// forwards what it understood is a relay that silently drops the fields it has
|
||||
// not learned about yet.
|
||||
//
|
||||
// Loop guard: a destination that is one of our OWN inbound ports on a local
|
||||
// address would come straight back in and be relayed again, for ever, at line
|
||||
// rate. Such a row is skipped and said so once — quietly dropping it would look
|
||||
// like a relay that does not work.
|
||||
func (m *Manager) RelayInbound(pkt []byte, fromPort int) {
|
||||
if len(pkt) == 0 {
|
||||
return
|
||||
}
|
||||
rows := m.Outbound(ServiceWSJTRelay)
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
ports := make(map[int]struct{}, len(m.inbound))
|
||||
for _, s := range m.inbound {
|
||||
ports[s.cfg.Port] = struct{}{}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, c := range rows {
|
||||
if isLoopback(c.DestinationIP) {
|
||||
if _, mine := ports[c.Port]; mine {
|
||||
m.relayLoopOnce(c)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Deliberately not sendTo: that logs a line per datagram, and this runs
|
||||
// on every decode of every period — it would bury the log within minutes.
|
||||
host := strings.TrimSpace(c.DestinationIP)
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
dst := fmt.Sprintf("%s:%d", host, c.Port)
|
||||
if err := SendUDP(dst, pkt); err != nil {
|
||||
m.relayErrOnce(c, dst, err)
|
||||
}
|
||||
}
|
||||
_ = fromPort
|
||||
}
|
||||
|
||||
// isLoopback reports whether a destination names this machine. Empty counts:
|
||||
// sendTo defaults it to 127.0.0.1.
|
||||
func isLoopback(host string) bool {
|
||||
h := strings.TrimSpace(host)
|
||||
if h == "" || h == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(h)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// relayLoopOnce and relayErrOnce keep a repeating relay complaint to one line a
|
||||
// session. Both fire per datagram otherwise, which on a busy band is hundreds a
|
||||
// minute and makes the log useless for anything else.
|
||||
func (m *Manager) relayLoopOnce(c Config) {
|
||||
m.relayWarnMu.Lock()
|
||||
defer m.relayWarnMu.Unlock()
|
||||
if m.relayWarned == nil {
|
||||
m.relayWarned = map[int64]bool{}
|
||||
}
|
||||
if m.relayWarned[c.ID] {
|
||||
return
|
||||
}
|
||||
m.relayWarned[c.ID] = true
|
||||
applog.Printf("udp: [%s] relay target %s:%d is one of OpsLog's OWN listening ports — "+
|
||||
"that would feed the stream back into itself for ever. Nothing is relayed on this row; "+
|
||||
"point it at the other application's port.", c.Name, c.DestinationIP, c.Port)
|
||||
}
|
||||
|
||||
func (m *Manager) relayErrOnce(c Config, dst string, err error) {
|
||||
m.relayWarnMu.Lock()
|
||||
defer m.relayWarnMu.Unlock()
|
||||
if m.relayWarned == nil {
|
||||
m.relayWarned = map[int64]bool{}
|
||||
}
|
||||
if m.relayWarned[-c.ID-1] {
|
||||
return
|
||||
}
|
||||
m.relayWarned[-c.ID-1] = true
|
||||
applog.Printf("udp: [%s] relay to %s failed: %v (said once)", c.Name, dst, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The relay exists so a second application can be fed the same WSJT-X stream,
|
||||
// and the ONE thing it must get right is that the bytes are unchanged. The
|
||||
// relays in the field prepend an origin header ("127.0.0.1:2237|") — which is
|
||||
// why stripForwarderHeader had to be written on the receiving side — so a
|
||||
// header here would inflict on the next program the fault we had to survive.
|
||||
func TestRelayForwardsVerbatim(t *testing.T) {
|
||||
dst, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer dst.Close()
|
||||
port := dst.LocalAddr().(*net.UDPAddr).Port
|
||||
|
||||
m := &Manager{outbound: []Config{{
|
||||
ID: 1, Name: "relay", ServiceType: ServiceWSJTRelay,
|
||||
DestinationIP: "127.0.0.1", Port: port,
|
||||
}}}
|
||||
|
||||
// A packet with bytes that no parser here understands, on purpose: the relay
|
||||
// must not care what it is carrying.
|
||||
sent := []byte{0xad, 0xbc, 0xcb, 0xda, 0x00, 0x00, 0x00, 0x63, 0xde, 0xad, 0xbe, 0xef}
|
||||
m.RelayInbound(sent, 2237)
|
||||
|
||||
buf := make([]byte, 1024)
|
||||
_ = dst.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
n, _, err := dst.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("nothing relayed: %v", err)
|
||||
}
|
||||
if got := buf[:n]; string(got) != string(sent) {
|
||||
t.Errorf("relayed % X, want % X — the bytes must go out unchanged", got, sent)
|
||||
}
|
||||
}
|
||||
|
||||
// A relay aimed at one of OpsLog's OWN listening ports would come straight back
|
||||
// in, be relayed again, and saturate the loopback within seconds. The row is
|
||||
// skipped instead.
|
||||
func TestRelayRefusesToFeedItself(t *testing.T) {
|
||||
own, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer own.Close()
|
||||
port := own.LocalAddr().(*net.UDPAddr).Port
|
||||
|
||||
m := &Manager{
|
||||
inbound: map[int64]*Server{
|
||||
7: {cfg: Config{ID: 7, Port: port, ServiceType: ServiceWSJT}},
|
||||
},
|
||||
outbound: []Config{{
|
||||
ID: 1, Name: "loop", ServiceType: ServiceWSJTRelay,
|
||||
DestinationIP: "127.0.0.1", Port: port,
|
||||
}},
|
||||
}
|
||||
m.RelayInbound([]byte{1, 2, 3, 4}, 2237)
|
||||
|
||||
buf := make([]byte, 64)
|
||||
_ = own.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
|
||||
if n, _, err := own.ReadFromUDP(buf); err == nil {
|
||||
t.Fatalf("relayed %d bytes back into our own listener — that is the loop", n)
|
||||
}
|
||||
}
|
||||
|
||||
// No relay rows configured must cost nothing and send nothing.
|
||||
func TestRelayWithNoRowsIsSilent(t *testing.T) {
|
||||
m := &Manager{}
|
||||
m.RelayInbound([]byte{1, 2, 3}, 2237) // must not panic
|
||||
m.RelayInbound(nil, 2237)
|
||||
}
|
||||
@@ -85,7 +85,18 @@ func reusingListenConfig() net.ListenConfig {
|
||||
// of the list for the rest of the session. More than half a day apart is read as
|
||||
// the wrong side of midnight and moved.
|
||||
func decodeTime(msSinceMidnight uint32) time.Time {
|
||||
now := time.Now().UTC()
|
||||
return decodeTimeAt(msSinceMidnight, time.Now().UTC())
|
||||
}
|
||||
|
||||
// decodeTimeAt is decodeTime with the clock passed in.
|
||||
//
|
||||
// Split out so the midnight rule can be tested at midnight instead of at
|
||||
// whatever time the suite happens to run: the test used to read the real clock
|
||||
// and failed every afternoon, because a 23:59:58 stamp seen at 17:00 is hours in
|
||||
// the future and no rule can make it otherwise. A test that fails for half the
|
||||
// day teaches everyone to ignore the suite.
|
||||
func decodeTimeAt(msSinceMidnight uint32, now time.Time) time.Time {
|
||||
now = now.UTC()
|
||||
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
at := midnight.Add(time.Duration(msSinceMidnight) * time.Millisecond)
|
||||
switch {
|
||||
@@ -112,7 +123,7 @@ type Event struct {
|
||||
|
||||
// A WSJT-X Decode (heard station) to render on the panadapter.
|
||||
DecodeCall string // transmitting (DE) callsign
|
||||
DecodeGrid string // 4-char grid, CQ decodes only
|
||||
DecodeGrid string // 4-char grid: from a CQ, or a reply answering with its locator
|
||||
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
||||
DecodeSNR int // reported SNR (dB)
|
||||
DecodeCQ bool // the decode was a CQ
|
||||
@@ -134,6 +145,17 @@ type Event struct {
|
||||
DecodeAudioHz int64 // audio offset inside the passband
|
||||
DecodeMs uint32 // the decode's raw ms-since-midnight, as sent
|
||||
DecodeLowConf bool
|
||||
// DecodeModeRaw is the mode field EXACTLY as the Decode carried it — the
|
||||
// one-character marker ("~", "+"), not the resolved name in Mode.
|
||||
//
|
||||
// Both are needed and they are not interchangeable. Mode is what the log and
|
||||
// the status resolver want. This is what a Reply must echo: the receiving
|
||||
// application matches a Reply against its own decode list field for field,
|
||||
// and JTDX rejects one whose mode reads "FT8" where it decoded "~" — the
|
||||
// click is accepted, nothing is transmitted, and nothing is reported.
|
||||
DecodeModeRaw string
|
||||
// DecodeMsgRaw is the message as sent, untrimmed — see DecodeModeRaw.
|
||||
DecodeMsgRaw string
|
||||
// ProgramID is the sending application's own id ("WSJT-X", "MSHV", or
|
||||
// "WSJT-X - 2" for a second instance started with --rig-name). It is what
|
||||
// tells two receivers apart on one multicast group — and it is the address a
|
||||
@@ -145,7 +167,11 @@ type Event struct {
|
||||
// true while the carrier is actually up. From Status, so ~1 Hz.
|
||||
TxMessage string
|
||||
Transmitting bool
|
||||
DECall string // the operator's own call, as the digital app knows it
|
||||
// TxEnabled is the sender's Enable Tx toggle — see WSJTEvent.TxEnabled. The
|
||||
// watchdog clears it, which is the only reliable sign an exchange was
|
||||
// abandoned rather than merely paused between overs.
|
||||
TxEnabled bool
|
||||
DECall string // the operator's own call, as the digital app knows it
|
||||
|
||||
// ClearCall is set when a WSJT/JTDX/MSHV Status message reports an EMPTY DX
|
||||
// Call after previously reporting one — i.e. the operator cleared the call in
|
||||
@@ -162,7 +188,10 @@ type Event struct {
|
||||
|
||||
// Server is a single inbound UDP listener.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
cfg Config
|
||||
// mgr is the owning Manager, so a listener can reach the outbound rows.
|
||||
// The relay is the only thing that needs it.
|
||||
mgr *Manager
|
||||
conn *net.UDPConn
|
||||
out chan<- Event
|
||||
stop chan struct{}
|
||||
@@ -230,10 +259,11 @@ func describePacket(pkt []byte) string {
|
||||
return fmt.Sprintf("%d bytes | text %q%s | hex %s%s", len(pkt), text.String(), more, strings.TrimSpace(hex.String()), more)
|
||||
}
|
||||
|
||||
func newServer(cfg Config, out chan<- Event) *Server {
|
||||
func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
out: out,
|
||||
mgr: mgr,
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
@@ -399,6 +429,17 @@ func (s *Server) noteIgnoredADIF(pkt []byte) {
|
||||
}
|
||||
|
||||
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
// Relay FIRST, and unconditionally: before parsing, and whatever the parse
|
||||
// then makes of it. A datagram this build cannot decode is still one the
|
||||
// application downstream may understand, and a relay that forwards only what
|
||||
// it understood drops exactly the fields it has yet to learn about.
|
||||
//
|
||||
// WSJT listeners only. The other inbound services are text protocols with no
|
||||
// second consumer to speak of, and relaying an ADIF record twice would log
|
||||
// the contact twice.
|
||||
if s.cfg.ServiceType == ServiceWSJT && s.mgr != nil {
|
||||
s.mgr.RelayInbound(pkt, s.cfg.Port)
|
||||
}
|
||||
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
|
||||
switch s.cfg.ServiceType {
|
||||
case ServiceWSJT:
|
||||
@@ -477,7 +518,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
ev.DecodeSNR = w.SNR
|
||||
ev.DecodeCQ = w.IsCQ
|
||||
ev.Mode = DecodeModeName(w.Mode, statusMode)
|
||||
ev.DecodeModeRaw = w.Mode
|
||||
ev.DecodeMsg = w.DecodeMsg
|
||||
ev.DecodeMsgRaw = w.DecodeMsgRaw
|
||||
ev.DecodeAt = decodeTime(w.DecodeMsSinceMidnight)
|
||||
ev.DecodeTRPeriod = tr
|
||||
ev.DecodeDial = dial
|
||||
@@ -677,6 +720,13 @@ type Manager struct {
|
||||
httpFailMu sync.Mutex
|
||||
httpFailAt map[int64]time.Time
|
||||
|
||||
// relayWarned keeps a relay row's complaint (a loop target, a dead
|
||||
// destination) to one line a session. Keyed by row id for the loop warning
|
||||
// and by -id-1 for the send failure, so one row can say each once.
|
||||
// Without this a busy band writes hundreds of identical lines a minute.
|
||||
relayWarnMu sync.Mutex
|
||||
relayWarned map[int64]bool
|
||||
|
||||
mu sync.Mutex
|
||||
inbound map[int64]*Server
|
||||
outbound []Config
|
||||
@@ -729,7 +779,7 @@ func (m *Manager) Reload(ctx context.Context) []string {
|
||||
m.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
srv := newServer(c, m.out)
|
||||
srv := newServer(c, m.out, m)
|
||||
if err := srv.start(); err != nil {
|
||||
applog.Printf("udp: start %q failed: %v", c.Name, err)
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", c.Name, err))
|
||||
|
||||
@@ -51,7 +51,7 @@ type WSJTEvent struct {
|
||||
// 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
|
||||
DecodeGrid string // 4-char grid, CQ decodes only — the exchange carries none
|
||||
DecodeGrid string // 4-char grid: from a CQ, or a reply that answers with its locator
|
||||
DeltaFreqHz int64 // audio offset within the passband (Hz)
|
||||
SNR int // reported signal-to-noise (dB)
|
||||
IsCQ bool // the decode was a CQ call
|
||||
@@ -64,6 +64,12 @@ type WSJTEvent struct {
|
||||
// exchange is what tells an operator where a station is in a QSO, and no set
|
||||
// of extracted fields says "R-09" the way the line itself does.
|
||||
DecodeMsg string
|
||||
// DecodeMsgRaw is the message EXACTLY as the datagram carried it, trailing
|
||||
// spaces and all. DecodeMsg above is trimmed for display; a Reply has to
|
||||
// send this one, because the receiving application matches a Reply against
|
||||
// its own decode list field for field and a stripped space is a mismatch it
|
||||
// reports nowhere.
|
||||
DecodeMsgRaw string
|
||||
// DecodeMsSinceMidnight is the decode's own timestamp, in milliseconds since
|
||||
// 00:00 UTC, as the sender reported it. It is what groups decodes into T/R
|
||||
// PERIODS — arrival time cannot, since a whole period's decodes land in one
|
||||
@@ -80,8 +86,14 @@ type WSJTEvent struct {
|
||||
// from Status, so they arrive about once a second.
|
||||
TxMessage string
|
||||
Transmitting bool
|
||||
DECall string // the operator's own callsign, as the digital app knows it
|
||||
DEGrid string // and their square
|
||||
// TxEnabled is the sender's "Enable Tx" toggle. It is what the WATCHDOG
|
||||
// turns off: the application stops transmitting but leaves the DX call set,
|
||||
// so a caller that reads only the DX call believes the QSO is still running
|
||||
// and waits for ever. This is the difference between "between overs" and
|
||||
// "given up".
|
||||
TxEnabled bool
|
||||
DECall string // the operator's own callsign, as the digital app knows it
|
||||
DEGrid string // and their square
|
||||
// TRPeriod is the transmit/receive period in seconds (15 for FT8, 7 or 8 for
|
||||
// FT4 depending on the sender's rounding). The authority on how long a slot
|
||||
// is — better than inferring it from the mode name, which says nothing about
|
||||
@@ -214,6 +226,7 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||
}
|
||||
}
|
||||
ev.Transmitting = transmitting != 0
|
||||
ev.TxEnabled = txEnabled != 0
|
||||
// rx_df, tx_df
|
||||
var i32 int32
|
||||
for i := 0; i < 2; i++ {
|
||||
@@ -331,6 +344,7 @@ func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||
ev.SNR = int(snr)
|
||||
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||
ev.DecodeMsg = strings.TrimSpace(msg)
|
||||
ev.DecodeMsgRaw = msg
|
||||
ev.DecodeMsSinceMidnight = t32
|
||||
ev.LowConfidence = lowConf != 0
|
||||
ev.OffAir = offAir != 0
|
||||
@@ -386,7 +400,21 @@ func wsjtSender(message string) (call string, isCQ bool, grid string) {
|
||||
return "", true, ""
|
||||
}
|
||||
// Standard exchange: the DE (sender) call is the second token.
|
||||
//
|
||||
// And the THIRD token is a grid whenever the station is answering with its
|
||||
// locator — "C91RU IZ5EME JN52", the ordinary first reply to a CQ. This used
|
||||
// to return no grid at all on the grounds that "the exchange carries none",
|
||||
// which is simply not true: it is the commonest grid-bearing message on a
|
||||
// busy band, and only a CQ being read meant a station whose CQ we missed had
|
||||
// no known grid — so it could never be flagged as a new one. GridTracker
|
||||
// reads both, which is why its wanted-grid count ran so far ahead of ours.
|
||||
//
|
||||
// isGridField is what keeps the reports and sign-offs out: -05, R-05, RRR,
|
||||
// 73 and RR73 all fail it, on length, charset or by name.
|
||||
if len(f) >= 2 && looksLikeCall(f[1]) {
|
||||
if len(f) >= 3 && isGridField(f[2]) {
|
||||
return f[1], false, f[2]
|
||||
}
|
||||
return f[1], false, ""
|
||||
}
|
||||
return "", false, ""
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// WSJT-X Free Text (message type 9) — "put this in the free-text box, and send".
|
||||
//
|
||||
// Free Text type 9
|
||||
// id utf8 the target application's own id
|
||||
// text utf8 the message
|
||||
// send bool true = transmit it now, false = only fill the box
|
||||
//
|
||||
// What this is FOR, and what it is not:
|
||||
//
|
||||
// It is the one "transmit this now" primitive that behaves the same on WSJT-X,
|
||||
// WSJT-Z, JTDX, JTDX-Improved and MSHV, because it needs no decode to match
|
||||
// against — unlike Reply, which the receiving application will only action if it
|
||||
// can find the exact decode being answered.
|
||||
//
|
||||
// It is NOT a way to call a station. FT8 free text is 13 characters, so a
|
||||
// standard "<DX> <ME> <GRID>" does not fit, and more importantly the text goes
|
||||
// out AS free text without setting the DX Call — so the sequencer never takes
|
||||
// over, and no report, RR73 or logging prompt follows. Use it for a 73, a short
|
||||
// note or a CQ; use Reply to start a QSO.
|
||||
const wsjtMsgFreeText = 9
|
||||
|
||||
// EncodeFreeText builds the datagram. send=false fills the box without keying.
|
||||
func EncodeFreeText(programID, text string, send bool) []byte {
|
||||
var b bytes.Buffer
|
||||
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
|
||||
_ = binary.Write(&b, binary.BigEndian, uint32(2)) // schema 2 — what every current sender speaks
|
||||
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgFreeText))
|
||||
writeQString(&b, programID)
|
||||
writeQString(&b, text)
|
||||
var s uint8
|
||||
if send {
|
||||
s = 1
|
||||
}
|
||||
_ = binary.Write(&b, binary.BigEndian, s)
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// SendFreeText hands a free-text message to one decoding application.
|
||||
//
|
||||
// Routed by program id to the address that instance's packets arrive from, for
|
||||
// the same reason as SendReply and SendHaltTx: two receivers can share one
|
||||
// multicast group, and transmitting from the wrong one is worse than not
|
||||
// transmitting at all.
|
||||
func (m *Manager) SendFreeText(programID, text string, send bool) error {
|
||||
if strings.TrimSpace(programID) == "" {
|
||||
return fmt.Errorf("no application id — cannot tell which receiver to send with")
|
||||
}
|
||||
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(EncodeFreeText(programID, text, send), addr); err != nil {
|
||||
return fmt.Errorf("send free text to %s at %s: %w", programID, addr, err)
|
||||
}
|
||||
applog.Printf("udp: free text sent to %s at %s — %q (send=%v)", programID, addr, text, send)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("no packet has arrived from %q yet — nothing to send to", programID)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The wire format, byte for byte. A malformed Free Text is discarded in silence
|
||||
// by the far end, exactly like a malformed Reply — so a wrong byte here shows up
|
||||
// as a button that does nothing.
|
||||
func TestEncodeFreeText(t *testing.T) {
|
||||
got := EncodeFreeText("JTDX", "73 GL", true)
|
||||
want := []byte{
|
||||
0xad, 0xbc, 0xcb, 0xda, // magic
|
||||
0x00, 0x00, 0x00, 0x02, // schema 2
|
||||
0x00, 0x00, 0x00, 0x09, // type 9 — Free Text
|
||||
0x00, 0x00, 0x00, 0x04, 'J', 'T', 'D', 'X',
|
||||
0x00, 0x00, 0x00, 0x05, '7', '3', ' ', 'G', 'L',
|
||||
0x01, // send now
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("EncodeFreeText\n got %#v\nwant %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// send=false fills the box without keying — the difference is one byte, and
|
||||
// getting it backwards would transmit when the operator only meant to prepare.
|
||||
func TestEncodeFreeTextWithoutSending(t *testing.T) {
|
||||
got := EncodeFreeText("MSHV", "", false)
|
||||
if got[len(got)-1] != 0x00 {
|
||||
t.Errorf("send flag = %#x, want 0 — this would transmit unasked", got[len(got)-1])
|
||||
}
|
||||
// An empty text is a zero-length QString, never the -1 that means null:
|
||||
// WSJT-X drops a packet with a null where it expects text.
|
||||
if !bytes.Equal(got[len(got)-5:len(got)-1], []byte{0, 0, 0, 0}) {
|
||||
t.Errorf("empty text was not encoded as a zero-length string: % X", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendFreeTextRejectsEmptyID(t *testing.T) {
|
||||
m := &Manager{}
|
||||
if err := m.SendFreeText(" ", "73", true); err == nil {
|
||||
t.Fatal("expected an error for a blank program id")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// WSJT-X Halt Tx (message type 8) — "stop transmitting".
|
||||
//
|
||||
// The same two things the Halt Tx button and the Enable Tx toggle do in the
|
||||
// application's own window:
|
||||
//
|
||||
// Halt Tx type 8
|
||||
// id utf8 the target application's own id
|
||||
// auto_tx_only bool false = stop the transmission NOW
|
||||
// true = let this over finish, then stop auto-Tx
|
||||
//
|
||||
// Both are useful and they are not the same operation. Stopping mid-over is what
|
||||
// you want when you have called the wrong station or realised the frequency is
|
||||
// occupied; finishing the over first is what you want when the QSO is complete
|
||||
// and cutting the transmission would leave the other end waiting for a report
|
||||
// that never lands.
|
||||
const wsjtMsgHaltTx = 8
|
||||
|
||||
// EncodeHaltTx builds the datagram. autoTxOnly false halts immediately.
|
||||
func EncodeHaltTx(programID string, autoTxOnly bool) []byte {
|
||||
var b bytes.Buffer
|
||||
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
|
||||
_ = binary.Write(&b, binary.BigEndian, uint32(2)) // schema 2 — what every current sender speaks
|
||||
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgHaltTx))
|
||||
writeQString(&b, programID)
|
||||
var auto uint8
|
||||
if autoTxOnly {
|
||||
auto = 1
|
||||
}
|
||||
_ = binary.Write(&b, binary.BigEndian, auto)
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// SendHaltTx tells one decoding application to stop transmitting.
|
||||
//
|
||||
// Routed by PROGRAM ID and answered back to the address that instance's packets
|
||||
// arrive from, exactly like SendReply: two receivers can share one multicast
|
||||
// group, and halting the 20 m instance because the 6 m one is transmitting would
|
||||
// be worse than doing nothing.
|
||||
func (m *Manager) SendHaltTx(programID string, autoTxOnly bool) error {
|
||||
if strings.TrimSpace(programID) == "" {
|
||||
return fmt.Errorf("no application id — cannot tell which receiver to halt")
|
||||
}
|
||||
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(EncodeHaltTx(programID, autoTxOnly), addr); err != nil {
|
||||
return fmt.Errorf("send halt to %s at %s: %w", programID, addr, err)
|
||||
}
|
||||
applog.Printf("udp: halt tx sent to %s at %s (auto_tx_only=%v)", programID, addr, autoTxOnly)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("no packet has arrived from %q yet — nothing to halt", programID)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The Halt Tx datagram, byte for byte. A wrong byte here does not fail loudly —
|
||||
// WSJT-X drops a malformed packet in silence, and the operator sees a Halt
|
||||
// button that simply does nothing.
|
||||
func TestEncodeHaltTx(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
autoTxOnly bool
|
||||
want []byte
|
||||
}{
|
||||
{
|
||||
name: "halt now",
|
||||
id: "WSJT-X",
|
||||
want: []byte{
|
||||
0xad, 0xbc, 0xcb, 0xda, // magic
|
||||
0x00, 0x00, 0x00, 0x02, // schema 2
|
||||
0x00, 0x00, 0x00, 0x08, // type 8 — Halt Tx
|
||||
0x00, 0x00, 0x00, 0x06, // id length
|
||||
'W', 'S', 'J', 'T', '-', 'X',
|
||||
0x00, // auto_tx_only = false → stop mid-over
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "finish the over first",
|
||||
id: "MSHV",
|
||||
autoTxOnly: true,
|
||||
want: []byte{
|
||||
0xad, 0xbc, 0xcb, 0xda,
|
||||
0x00, 0x00, 0x00, 0x02,
|
||||
0x00, 0x00, 0x00, 0x08,
|
||||
0x00, 0x00, 0x00, 0x04,
|
||||
'M', 'S', 'H', 'V',
|
||||
0x01,
|
||||
},
|
||||
},
|
||||
{
|
||||
// An empty id is length 0, never the -1 that means a null QString:
|
||||
// a null where text is expected makes WSJT-X discard the packet.
|
||||
name: "empty id is a zero-length string",
|
||||
id: "",
|
||||
want: []byte{
|
||||
0xad, 0xbc, 0xcb, 0xda,
|
||||
0x00, 0x00, 0x00, 0x02,
|
||||
0x00, 0x00, 0x00, 0x08,
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x00,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := EncodeHaltTx(tc.id, tc.autoTxOnly)
|
||||
if !bytes.Equal(got, tc.want) {
|
||||
t.Errorf("EncodeHaltTx(%q, %v)\n got %#v\nwant %#v", tc.id, tc.autoTxOnly, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SendHaltTx must refuse an empty id rather than broadcast a halt at whatever
|
||||
// application happens to answer: with two receivers sharing a multicast group,
|
||||
// halting the wrong one is worse than doing nothing.
|
||||
func TestSendHaltTxRejectsEmptyID(t *testing.T) {
|
||||
m := &Manager{}
|
||||
if err := m.SendHaltTx(" ", false); err == nil {
|
||||
t.Fatal("expected an error for a blank program id")
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,16 @@ func (m *Manager) SendReply(r Reply) error {
|
||||
if _, err := conn.WriteToUDP(pkt, addr); err != nil {
|
||||
return fmt.Errorf("send reply to %s at %s: %w", r.ProgramID, addr, err)
|
||||
}
|
||||
applog.Printf("udp: reply sent to %s at %s — %q", r.ProgramID, addr, r.Message)
|
||||
// Says whether the decode being answered was a CQ, because that is what
|
||||
// decides whether the far end will act on it at all: the protocol only
|
||||
// requires a Reply to be honoured for a CQ or QRZ. Without this the log
|
||||
// showed a reply going out and nothing happening, and the reason had to
|
||||
// be deduced from the message text by eye.
|
||||
kind := "not a CQ — the far end may ignore it"
|
||||
if strings.HasPrefix(strings.TrimSpace(strings.ToUpper(r.Message)), "CQ ") {
|
||||
kind = "CQ"
|
||||
}
|
||||
applog.Printf("udp: reply sent to %s at %s — %q (%s)", r.ProgramID, addr, r.Message, kind)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("no packet has arrived from %q yet — nothing to answer to", r.ProgramID)
|
||||
|
||||
Reference in New Issue
Block a user