A digital app (JTDX/WSJT-X) sharing OpsLog's rig in "Fake It" split follows the dial by polling get_freq. Freq() is the last polled value and lags a set_freq by a poll cycle, so right after a transmission the client read the still-shifted frequency, mistook it for a manual QSY and adopted it — the dial crept down every over and never came back. get_freq now echoes the last commanded frequency until the rig confirms it (or a short deadline passes), closing the race. Backend-agnostic, so it fixes every rig, not just Kenwood.
293 lines
9.9 KiB
Go
293 lines
9.9 KiB
Go
package rigctld
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
// fakeRig stands in for the CAT manager.
|
|
type fakeRig struct {
|
|
mu sync.Mutex
|
|
freq int64
|
|
mode string
|
|
split bool
|
|
txFreq int64
|
|
ptt bool
|
|
setFreqs []int64
|
|
setModes []string
|
|
failSet bool
|
|
lagSet bool // record the command but do not move freq — simulate poll lag
|
|
}
|
|
|
|
func (f *fakeRig) Freq() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.freq }
|
|
func (f *fakeRig) Mode() string { f.mu.Lock(); defer f.mu.Unlock(); return f.mode }
|
|
func (f *fakeRig) Split() (bool, int64) { f.mu.Lock(); defer f.mu.Unlock(); return f.split, f.txFreq }
|
|
func (f *fakeRig) SetFreq(hz int64) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.failSet {
|
|
return fmt.Errorf("rig refused")
|
|
}
|
|
f.setFreqs = append(f.setFreqs, hz)
|
|
if !f.lagSet {
|
|
f.freq = hz
|
|
}
|
|
return nil
|
|
}
|
|
func (f *fakeRig) SetMode(m string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.failSet {
|
|
return fmt.Errorf("rig refused")
|
|
}
|
|
f.mode = m
|
|
f.setModes = append(f.setModes, m)
|
|
return nil
|
|
}
|
|
func (f *fakeRig) SetPTT(on bool) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.failSet {
|
|
return fmt.Errorf("rig refused")
|
|
}
|
|
f.ptt = on
|
|
return nil
|
|
}
|
|
|
|
// The command table. These exact strings are what WSJT-X and MSHV put on the
|
|
// wire, so they are the contract — a reply in the wrong shape does not degrade
|
|
// gracefully, the client simply refuses to work with the rig.
|
|
func TestHandleCommands(t *testing.T) {
|
|
rig := &fakeRig{freq: 14074000, mode: "FT8", split: true, txFreq: 14100000}
|
|
s := New(0, rig, nil)
|
|
|
|
cases := []struct{ in, want string }{
|
|
{"f", "14074000\n"},
|
|
{"\\get_freq", "14074000\n"},
|
|
{"m", "PKTUSB\n3000\n"}, // a digital mode reads as PKTUSB
|
|
{"t", "0\n"}, // PTT always reads RX — see the comment
|
|
{"v", "VFOA\n"},
|
|
{"s", "1\nVFOB\n"}, // split on, TX on B
|
|
{"i", "14100000\n"}, // split TX frequency
|
|
{"F 14200000", "RPRT 0\n"},
|
|
{"F 14200000.000000", "RPRT 0\n"}, // the float form clients also send
|
|
{"M USB 2400", "RPRT 0\n"},
|
|
{"T 1", "RPRT 0\n"},
|
|
{"V VFOB", "RPRT 0\n"}, // accepted and ignored, never an error
|
|
{"S 1 VFOB", "RPRT 0\n"},
|
|
{"\\chk_vfo", "CHKVFO 0\n"},
|
|
{"F", "RPRT -1\n"}, // missing argument
|
|
{"F not_a_number", "RPRT -1\n"},
|
|
{"Z", "RPRT -11\n"}, // unknown → answered, never silence
|
|
{"", ""},
|
|
}
|
|
for _, c := range cases {
|
|
got, _ := s.handle(c.in)
|
|
if got != c.want {
|
|
t.Errorf("handle(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
if q := func() bool { _, q := s.handle("q"); return q }(); !q {
|
|
t.Error("q must end the session")
|
|
}
|
|
}
|
|
|
|
// Hamlib's VFO dialect. JTDX names the target VFO before the value — "F VFOA
|
|
// 14074000" — where MSHV sends "F 14074000". Reading the VFO name as the
|
|
// frequency is what produced "Hamlib error: Invalid parameter while setting
|
|
// frequency" on JTDX while MSHV worked perfectly.
|
|
func TestHandleAcceptsVFOPrefixedCommands(t *testing.T) {
|
|
rig := &fakeRig{freq: 7074000, mode: "SSB"}
|
|
s := New(0, rig, nil)
|
|
|
|
if got, _ := s.handle("F VFOA 14074000"); got != "RPRT 0\n" {
|
|
t.Fatalf("handle(\"F VFOA 14074000\") = %q, want RPRT 0", got)
|
|
}
|
|
if got := rig.Freq(); got != 14074000 {
|
|
t.Errorf("frequency = %d, want 14074000 — the VFO name swallowed the value", got)
|
|
}
|
|
if got, _ := s.handle("M VFOA USB 2400"); got != "RPRT 0\n" {
|
|
t.Errorf("handle(\"M VFOA USB 2400\") = %q, want RPRT 0", got)
|
|
}
|
|
if got, _ := s.handle("T VFOA 1"); got != "RPRT 0\n" {
|
|
t.Errorf("handle(\"T VFOA 1\") = %q, want RPRT 0", got)
|
|
}
|
|
// A read with the VFO named must still answer the value, not an error.
|
|
if got, _ := s.handle("f VFOA"); got != "14074000\n" {
|
|
t.Errorf("handle(\"f VFOA\") = %q, want the frequency", got)
|
|
}
|
|
// And the plain dialect must keep working — this is an ADDITION, not a swap.
|
|
if got, _ := s.handle("F 21074000"); got != "RPRT 0\n" {
|
|
t.Errorf("plain set_freq broke: %q", got)
|
|
}
|
|
// "S 1 VFOB" starts with the split flag, not a VFO: nothing must be eaten.
|
|
if got, _ := s.handle("S 1 VFOB"); got != "RPRT 0\n" {
|
|
t.Errorf("handle(\"S 1 VFOB\") = %q, want RPRT 0", got)
|
|
}
|
|
}
|
|
|
|
// A rig that refuses must produce an error report, not a success — a client told
|
|
// "RPRT 0" believes the radio moved and will log the wrong frequency.
|
|
func TestHandleReportsBackendFailure(t *testing.T) {
|
|
s := New(0, &fakeRig{failSet: true}, nil)
|
|
for _, in := range []string{"F 14200000", "M USB 2400", "T 1"} {
|
|
if got, _ := s.handle(in); got != "RPRT -9\n" {
|
|
t.Errorf("handle(%q) with a failing rig = %q, want RPRT -9", in, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The FT8 "Fake It" drift. JTDX/WSJT-X shift the dial down on TX and restore it
|
|
// on RX, and they follow the dial by polling get_freq. Our Freq() is the last
|
|
// POLLED value and lags a set_freq by a poll cycle, so right after the restore
|
|
// the client used to read the still-shifted frequency, take it for a manual QSY
|
|
// and adopt it — the dial crept down every over and never came back. get_freq
|
|
// now echoes the last commanded frequency until the rig confirms it.
|
|
func TestFakeItSplitDoesNotDriftTheDial(t *testing.T) {
|
|
// lagSet: SetFreq only records the command; we drive the poll catch-up by hand
|
|
// to land inside the exact window the drift lived in.
|
|
rig := &fakeRig{freq: 14074000, lagSet: true}
|
|
s := New(0, rig, nil)
|
|
|
|
if got, _ := s.handle("f"); got != "14074000\n" {
|
|
t.Fatalf("baseline get_freq = %q, want 14074000", got)
|
|
}
|
|
setFreq := func(hz int64) { rig.mu.Lock(); rig.freq = hz; rig.mu.Unlock() }
|
|
|
|
for cycle := 0; cycle < 5; cycle++ {
|
|
// TX: the client shifts the dial down for the over.
|
|
if got, _ := s.handle("F 14073500"); got != "RPRT 0\n" {
|
|
t.Fatalf("cycle %d TX set_freq = %q", cycle, got)
|
|
}
|
|
// Mid-TX, before the rig reports the move, the client reads back exactly
|
|
// what it commanded — not the stale 14074000.
|
|
if got, _ := s.handle("f"); got != "14073500\n" {
|
|
t.Fatalf("cycle %d TX get_freq = %q, want commanded 14073500", cycle, got)
|
|
}
|
|
setFreq(14073500) // poll catches up to the shifted dial
|
|
|
|
// RX: the client restores the dial.
|
|
if got, _ := s.handle("F 14074000"); got != "RPRT 0\n" {
|
|
t.Fatalf("cycle %d RX set_freq = %q", cycle, got)
|
|
}
|
|
// The rig has not yet reported the restore (still 14073500). Before the fix
|
|
// this returned 14073500 and JTDX adopted it — the cumulative drift. Now it
|
|
// echoes the restore, so the dial holds.
|
|
if got, _ := s.handle("f"); got != "14074000\n" {
|
|
t.Fatalf("cycle %d RX get_freq = %q, want restored 14074000 — dial drifted", cycle, got)
|
|
}
|
|
setFreq(14074000) // poll catches up to the restored dial
|
|
}
|
|
|
|
// Once a poll confirms the commanded dial, the echo is released (in the field
|
|
// the client polls continuously, so this happens within a cycle).
|
|
if got, _ := s.handle("f"); got != "14074000\n" {
|
|
t.Fatalf("confirming get_freq = %q, want 14074000", got)
|
|
}
|
|
// A genuine knob turn now surfaces at once.
|
|
setFreq(14075000)
|
|
if got, _ := s.handle("f"); got != "14075000\n" {
|
|
t.Errorf("manual QSY get_freq = %q, want 14075000 — echo hid a real move", got)
|
|
}
|
|
}
|
|
|
|
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
|
|
// line as the protocol version and refuses to continue if the block is short or
|
|
// misshapen. Pinning its shape is what stops a well-meaning edit from silently
|
|
// breaking every client.
|
|
func TestDumpStateShape(t *testing.T) {
|
|
lines := strings.Split(strings.TrimRight(dumpState, "\n"), "\n")
|
|
if len(lines) < 20 {
|
|
t.Fatalf("dump_state has %d lines — clients expect the full capability block", len(lines))
|
|
}
|
|
if lines[0] != "0" {
|
|
t.Errorf("dump_state protocol version = %q, want \"0\"", lines[0])
|
|
}
|
|
// The frequency-range lines must carry seven fields, or the client's parse
|
|
// slides and every later capability is read from the wrong place.
|
|
for _, i := range []int{3, 5} {
|
|
if n := len(strings.Fields(lines[i])); n != 7 {
|
|
t.Errorf("dump_state line %d has %d fields, want 7: %q", i, n, lines[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// End to end over a real socket, because the framing (one reply per line,
|
|
// flushed immediately) is as much a part of the contract as the text.
|
|
func TestServerOverTCP(t *testing.T) {
|
|
rig := &fakeRig{freq: 7074000, mode: "SSB"}
|
|
s := New(0, rig, nil)
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
s.mu.Lock()
|
|
s.ln = ln
|
|
s.mu.Unlock()
|
|
go func() {
|
|
for {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go s.serve(c)
|
|
}
|
|
}()
|
|
defer s.Stop()
|
|
|
|
c, err := net.DialTimeout("tcp", ln.Addr().String(), dialTimeout)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
defer c.Close()
|
|
r := bufio.NewReader(c)
|
|
|
|
if _, err := c.Write([]byte("f\n")); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if strings.TrimSpace(line) != "7074000" {
|
|
t.Errorf("get_freq over TCP = %q, want 7074000", strings.TrimSpace(line))
|
|
}
|
|
|
|
if _, err := c.Write([]byte("F 14074000\n")); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
line, _ = r.ReadString('\n')
|
|
if strings.TrimSpace(line) != "RPRT 0" {
|
|
t.Errorf("set_freq over TCP = %q, want RPRT 0", strings.TrimSpace(line))
|
|
}
|
|
if got := rig.Freq(); got != 14074000 {
|
|
t.Errorf("rig frequency = %d, want 14074000 — the command never reached it", got)
|
|
}
|
|
}
|
|
|
|
func TestModeMapping(t *testing.T) {
|
|
for _, c := range []struct{ adif, hamlib string }{
|
|
{"SSB", "USB"}, {"LSB", "LSB"}, {"CW", "CW"}, {"RTTY", "RTTY"},
|
|
{"FT8", "PKTUSB"}, {"JS8", "PKTUSB"}, {"", "USB"},
|
|
} {
|
|
if got := adifToHamlib(c.adif); got != c.hamlib {
|
|
t.Errorf("adifToHamlib(%q) = %q, want %q", c.adif, got, c.hamlib)
|
|
}
|
|
}
|
|
// Digital comes back as DATA, never as a specific sub-mode: the CAT backend
|
|
// applies the operator's own digital default, so a client that switches the
|
|
// rig to data does not relabel a JS8 operator's QSOs as FT8.
|
|
for _, c := range []struct{ hamlib, adif string }{
|
|
{"PKTUSB", "DATA"}, {"PKTLSB", "DATA"}, {"DIGU", "DATA"},
|
|
{"USB", "USB"}, {"CWR", "CW"}, {"FMN", "FM"},
|
|
} {
|
|
if got := hamlibToADIF(c.hamlib); got != c.adif {
|
|
t.Errorf("hamlibToADIF(%q) = %q, want %q", c.hamlib, got, c.adif)
|
|
}
|
|
}
|
|
}
|