The Kenwood/Elecraft backend deliberately suspends its wire poll while PTT is held — a K3 answers "?;" to IF; during transmit, and treating that as a fault used to drop the whole CAT link. The consequence was that nothing watched the transmitter: a client that crashed, was closed, or simply had its socket shut under it left the rig on air. And shutting the socket is routine. reloadCATShare tears the sharing server down and rebuilds it on every settings save, so a Save while WSJT-X held PTT was enough. A K3 operator's log shows exactly that: "TX;" at 17:53:09, no "RX;" ever, the poll silent, and the rig still keyed 29 s later when the CAT link happened to be rebuilt. The server now drops PTT when a connection ends and when Stop() is called. Stop() runs before reloadCAT restarts the backend, so the unkey still reaches the radio. An atomic Swap keeps it once-only across the two paths.
319 lines
9.8 KiB
Go
319 lines
9.8 KiB
Go
package rigctld
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
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.freq = hz
|
|
f.setFreqs = append(f.setFreqs, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// get_ptt must echo the last commanded PTT state. WSJT-X/JTDX poll get_ptt while
|
|
// transmitting to confirm the rig is keyed; a blanket "0" made them decide PTT
|
|
// had failed and abort the over after a second or two.
|
|
func TestGetPTTEchoesSetPTT(t *testing.T) {
|
|
s := New(0, &fakeRig{}, nil)
|
|
if got, _ := s.handle("t"); got != "0\n" {
|
|
t.Fatalf("initial get_ptt = %q, want 0", got)
|
|
}
|
|
if got, _ := s.handle("T 1"); got != "RPRT 0\n" {
|
|
t.Fatalf("set_ptt 1 = %q, want RPRT 0", got)
|
|
}
|
|
if got, _ := s.handle("t"); got != "1\n" {
|
|
t.Fatalf("get_ptt after T 1 = %q, want 1 — client would abort TX", got)
|
|
}
|
|
if got, _ := s.handle("T 0"); got != "RPRT 0\n" {
|
|
t.Fatalf("set_ptt 0 = %q, want RPRT 0", got)
|
|
}
|
|
if got, _ := s.handle("t"); got != "0\n" {
|
|
t.Fatalf("get_ptt after T 0 = %q, want 0", 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)
|
|
}
|
|
}
|
|
|
|
// A client that vanishes mid-over must not leave the transmitter keyed. Nothing
|
|
// else would notice: the Kenwood/Elecraft backend stops polling while PTT is
|
|
// held, so a K3 was seen sitting in transmit for 29 s after WSJT-X's socket was
|
|
// closed under it by a settings save.
|
|
func TestPTTIsDroppedWhenTheClientDisappears(t *testing.T) {
|
|
rig := &fakeRig{freq: 14074000, mode: "FT8"}
|
|
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)
|
|
}
|
|
r := bufio.NewReader(c)
|
|
// "T 3" is what WSJT-X sends for PTT_ON_DATA — the form seen in the field.
|
|
if _, err := c.Write([]byte("T 3\n")); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
line, _ := r.ReadString('\n')
|
|
if strings.TrimSpace(line) != "RPRT 0" {
|
|
t.Fatalf("set_ptt = %q, want RPRT 0", strings.TrimSpace(line))
|
|
}
|
|
rig.mu.Lock()
|
|
keyed := rig.ptt
|
|
rig.mu.Unlock()
|
|
if !keyed {
|
|
t.Fatalf("rig is not keyed after T 3 — the test proves nothing")
|
|
}
|
|
|
|
_ = c.Close()
|
|
// The unkey happens on the serve goroutine's defer, so poll briefly.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
rig.mu.Lock()
|
|
keyed = rig.ptt
|
|
rig.mu.Unlock()
|
|
if !keyed {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Error("rig still keyed after the client disconnected — the transmitter was left on air")
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|