Files
OpsLog/internal/rigctld/rigctld_test.go
T
rouggy 38b480a985 feat: share the CAT link with other programs (Hamlib NET rigctl server)
A native CAT backend owns the rig's serial port, and Windows gives a COM port to
one process — so choosing native CAT locked WSJT-X, MSHV and JTDX out of the
radio entirely. That is the cost of dropping OmniRig, which was itself a sharing
layer, and it has to be paid back.

OpsLog now becomes the server, as wfview does. It speaks the Hamlib net rigctl
protocol, which every one of those programs supports natively (rig model "Hamlib
NET rigctl", 127.0.0.1:4532) with no driver to install. It sits in front of the
MANAGER, not a backend, so an operator on OmniRig, Flex, Icom or TCI gets the
same server.

Two details that decide whether a client works at all rather than degrading:
dump_state is parsed positionally and WSJT-X refuses to proceed without a
well-formed block, so it is written out in full and its shape is pinned by a
test; and set_vfo / set_split_vfo answer RPRT 0 rather than an error, because
OpsLog follows the rig's own VFO and a refusal makes WSJT-X abandon the
connection. Unknown commands answer RPRT -11 — never silence, which hangs a
client instead.

The whole protocol is tested against a fake rig, plus one end-to-end exchange
over a real socket, since the framing is as much the contract as the text.

Also: the Yaesu backend is confirmed working on a real FTDX10 (frequency, mode,
VFO, split), so its "not yet verified" note is now wrong and is corrected.
2026-07-29 10:49:14 +02:00

202 lines
5.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
}
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")
}
}
// 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)
}
}
}
// 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)
}
}
}