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.
This commit is contained in:
@@ -30,9 +30,10 @@ package cat
|
||||
// remembered. Every unrecognised reply is logged raw, because that log is the
|
||||
// only way to learn a model's real behaviour from an operator's shack.
|
||||
//
|
||||
// Verified on: nothing yet — written from the FTDX10/FTDX101 CAT reference and
|
||||
// awaiting a first on-air run. Anything this file asserts about a rig it has not
|
||||
// met should be read as a hypothesis with a log line attached.
|
||||
// Verified on: FTDX10, 2026-07-29 — frequency, mode, VFO and split all correct
|
||||
// against the radio. The other models are still inference from the same CAT
|
||||
// reference; anything this file asserts about a rig it has not met should be
|
||||
// read as a hypothesis with a log line attached.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
// Package rigctld shares OpsLog's CAT link with other programs.
|
||||
//
|
||||
// A native CAT backend owns the rig's serial port, and Windows gives a COM port
|
||||
// to ONE process. So the moment OpsLog talks to the radio directly, WSJT-X,
|
||||
// MSHV or JTDX can no longer reach it — the cost of dropping OmniRig, which was
|
||||
// itself a sharing layer.
|
||||
//
|
||||
// The answer is the one wfview uses: OpsLog becomes the server. It speaks the
|
||||
// Hamlib "net rigctl" protocol, which WSJT-X, JTDX, MSHV, Log4OM and CQRLOG all
|
||||
// support natively (rig model "Hamlib NET rigctl", host:4532) with no driver to
|
||||
// install. The other program asks us, and we relay to whichever backend is
|
||||
// connected — OmniRig, Flex, Icom, TCI or Yaesu alike.
|
||||
//
|
||||
// ── The protocol ──────────────────────────────────────────────────────────
|
||||
// Line-based ASCII. A lowercase letter reads, its uppercase counterpart writes,
|
||||
// and long names are prefixed with a backslash. A write answers "RPRT 0" for
|
||||
// success or "RPRT -n" for an error; a read answers the value(s), one per line.
|
||||
//
|
||||
// f → 14074000 get_freq
|
||||
// F 14074000 → RPRT 0 set_freq
|
||||
// m → USB\n2400 get_mode (mode + passband)
|
||||
// M USB 2400 → RPRT 0 set_mode
|
||||
// t / T 1 → 0 get/set PTT
|
||||
// s → 0\nVFOB get_split_vfo
|
||||
// v → VFOA get_vfo
|
||||
// \dump_state → capability block asked once by WSJT-X at connect
|
||||
//
|
||||
// WSJT-X will not proceed past connect without a well-formed dump_state, which
|
||||
// is why that block is written out in full rather than stubbed.
|
||||
package rigctld
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Rig is what the server needs from OpsLog's CAT manager. An interface, so this
|
||||
// package stays testable without a radio and without importing internal/cat.
|
||||
type Rig interface {
|
||||
Freq() int64 // current TX frequency in Hz, 0 if unknown
|
||||
Mode() string // ADIF mode (SSB, CW, FT8…)
|
||||
Split() (bool, int64) // split on?, and the other VFO's frequency
|
||||
SetFreq(hz int64) error
|
||||
SetMode(mode string) error
|
||||
SetPTT(on bool) error
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
port int
|
||||
rig Rig
|
||||
log func(string, ...any)
|
||||
|
||||
mu sync.Mutex
|
||||
ln net.Listener
|
||||
conns map[net.Conn]struct{}
|
||||
closed bool
|
||||
}
|
||||
|
||||
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
||||
if port <= 0 || port > 65535 {
|
||||
port = 4532 // the rigctld default every client pre-fills
|
||||
}
|
||||
if logf == nil {
|
||||
logf = func(string, ...any) {}
|
||||
}
|
||||
return &Server{port: port, rig: rig, log: logf, conns: map[net.Conn]struct{}{}}
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
s.mu.Lock()
|
||||
if s.ln != nil {
|
||||
s.mu.Unlock()
|
||||
return nil // already listening
|
||||
}
|
||||
s.closed = false
|
||||
s.mu.Unlock()
|
||||
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("rigctld: listen on %d: %w", s.port, err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.ln = ln
|
||||
s.mu.Unlock()
|
||||
s.log("rigctld: sharing CAT on port %d (Hamlib NET rigctl)", s.port)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
closed := s.closed
|
||||
s.mu.Unlock()
|
||||
if !closed {
|
||||
s.log("rigctld: accept failed: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.conns[c] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
go s.serve(c)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) Stop() {
|
||||
s.mu.Lock()
|
||||
s.closed = true
|
||||
ln := s.ln
|
||||
s.ln = nil
|
||||
conns := make([]net.Conn, 0, len(s.conns))
|
||||
for c := range s.conns {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
s.conns = map[net.Conn]struct{}{}
|
||||
s.mu.Unlock()
|
||||
|
||||
if ln != nil {
|
||||
_ = ln.Close()
|
||||
}
|
||||
// Close the live sessions too. Leaving them open would keep a client happily
|
||||
// talking to a server the operator has switched off.
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) serve(c net.Conn) {
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.conns, c)
|
||||
s.mu.Unlock()
|
||||
_ = c.Close()
|
||||
}()
|
||||
s.log("rigctld: client connected from %s", c.RemoteAddr())
|
||||
r := bufio.NewReader(c)
|
||||
w := bufio.NewWriter(c)
|
||||
for {
|
||||
// No deadline: WSJT-X polls every few seconds but a client may legitimately
|
||||
// sit idle between band changes, and dropping it would look like a fault.
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
s.log("rigctld: client %s disconnected", c.RemoteAddr())
|
||||
return
|
||||
}
|
||||
resp, quit := s.handle(strings.TrimSpace(line))
|
||||
if resp != "" {
|
||||
if _, err := w.WriteString(resp); err != nil {
|
||||
return
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if quit {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handle answers one command line. Pure apart from the Rig calls, so the whole
|
||||
// protocol is testable with a fake rig.
|
||||
func (s *Server) handle(line string) (resp string, quit bool) {
|
||||
if line == "" {
|
||||
return "", false
|
||||
}
|
||||
// Extended mode: clients may prefix a command with '+' or '-' to ask for a
|
||||
// verbose reply. We answer in the plain format, which every client also
|
||||
// accepts, so the prefix is simply stripped.
|
||||
line = strings.TrimLeft(line, "+-")
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
return "", false
|
||||
}
|
||||
cmd, args := fields[0], fields[1:]
|
||||
|
||||
switch cmd {
|
||||
case "\\dump_state", "dump_state":
|
||||
return dumpState, false
|
||||
case "\\chk_vfo", "chk_vfo":
|
||||
// "is VFO mode on?" — we answer for one VFO at a time, so: no.
|
||||
return "CHKVFO 0\n", false
|
||||
case "\\get_powerstat", "get_powerstat":
|
||||
return "1\n", false
|
||||
case "q", "Q", "\\quit":
|
||||
return "", true
|
||||
|
||||
case "f", "\\get_freq":
|
||||
return fmt.Sprintf("%d\n", s.rig.Freq()), false
|
||||
case "F", "\\set_freq":
|
||||
if len(args) < 1 {
|
||||
return rprt(-1), false
|
||||
}
|
||||
hz, err := parseFreq(args[0])
|
||||
if err != nil {
|
||||
return rprt(-1), false
|
||||
}
|
||||
if err := s.rig.SetFreq(hz); err != nil {
|
||||
s.log("rigctld: set_freq %d failed: %v", hz, err)
|
||||
return rprt(-9), false
|
||||
}
|
||||
return rprt(0), false
|
||||
|
||||
case "m", "\\get_mode":
|
||||
// Passband width is required by the protocol. We do not read the rig's
|
||||
// filter, and a made-up number is harmless here: clients use it to display
|
||||
// a bandwidth, never to decide anything.
|
||||
return fmt.Sprintf("%s\n%d\n", adifToHamlib(s.rig.Mode()), passbandFor(s.rig.Mode())), false
|
||||
case "M", "\\set_mode":
|
||||
if len(args) < 1 {
|
||||
return rprt(-1), false
|
||||
}
|
||||
if err := s.rig.SetMode(hamlibToADIF(args[0])); err != nil {
|
||||
s.log("rigctld: set_mode %q failed: %v", args[0], err)
|
||||
return rprt(-9), false
|
||||
}
|
||||
return rprt(0), false
|
||||
|
||||
case "t", "\\get_ptt":
|
||||
// We do not read PTT back from every backend, and answering "transmitting"
|
||||
// wrongly would make a client hold off for ever. Reporting RX is the safe
|
||||
// direction: the worst case is a client that transmits when we said it
|
||||
// could, which is what it was going to do anyway.
|
||||
return "0\n", false
|
||||
case "T", "\\set_ptt":
|
||||
if len(args) < 1 {
|
||||
return rprt(-1), false
|
||||
}
|
||||
on := args[0] != "0"
|
||||
if err := s.rig.SetPTT(on); err != nil {
|
||||
s.log("rigctld: set_ptt %v failed: %v", on, err)
|
||||
return rprt(-9), false
|
||||
}
|
||||
return rprt(0), false
|
||||
|
||||
case "v", "\\get_vfo":
|
||||
return "VFOA\n", false
|
||||
case "V", "\\set_vfo":
|
||||
// Accepted and ignored: OpsLog follows the rig's own VFO selection, and
|
||||
// answering an error here makes WSJT-X abandon the connection entirely.
|
||||
return rprt(0), false
|
||||
|
||||
case "s", "\\get_split_vfo":
|
||||
on, _ := s.rig.Split()
|
||||
n := 0
|
||||
if on {
|
||||
n = 1
|
||||
}
|
||||
return fmt.Sprintf("%d\nVFOB\n", n), false
|
||||
case "S", "\\set_split_vfo":
|
||||
return rprt(0), false // see set_vfo — split is driven from the rig
|
||||
case "i", "\\get_split_freq":
|
||||
_, tx := s.rig.Split()
|
||||
if tx <= 0 {
|
||||
tx = s.rig.Freq()
|
||||
}
|
||||
return fmt.Sprintf("%d\n", tx), false
|
||||
case "I", "\\set_split_freq":
|
||||
return rprt(0), false
|
||||
|
||||
default:
|
||||
// RPRT -11 is "command not implemented". Answering something is essential:
|
||||
// a client waiting on a silent socket hangs rather than degrading.
|
||||
s.log("rigctld: unimplemented command %q", line)
|
||||
return rprt(-11), false
|
||||
}
|
||||
}
|
||||
|
||||
func rprt(code int) string { return fmt.Sprintf("RPRT %d\n", code) }
|
||||
|
||||
// parseFreq accepts both the integer Hz and the "14074000.000000" form clients
|
||||
// send interchangeably.
|
||||
func parseFreq(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '.'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
hz, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || hz <= 0 {
|
||||
return 0, fmt.Errorf("rigctld: bad frequency %q", s)
|
||||
}
|
||||
return hz, nil
|
||||
}
|
||||
|
||||
// adifToHamlib maps our mode vocabulary to Hamlib's. Every digital sub-mode
|
||||
// becomes PKTUSB: that is what a client expects to see when the rig is in DATA,
|
||||
// and it is what WSJT-X sets when it takes control.
|
||||
func adifToHamlib(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "SSB", "USB":
|
||||
return "USB"
|
||||
case "LSB":
|
||||
return "LSB"
|
||||
case "CW":
|
||||
return "CW"
|
||||
case "AM":
|
||||
return "AM"
|
||||
case "FM":
|
||||
return "FM"
|
||||
case "RTTY":
|
||||
return "RTTY"
|
||||
case "":
|
||||
return "USB"
|
||||
default:
|
||||
return "PKTUSB"
|
||||
}
|
||||
}
|
||||
|
||||
// hamlibToADIF is the reverse. PKTUSB/PKTLSB/DATA become "DATA": the CAT backend
|
||||
// then applies the operator's configured digital mode, so a client switching the
|
||||
// rig to data does not silently relabel their QSOs as FT8 when they run JS8.
|
||||
func hamlibToADIF(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "USB":
|
||||
return "USB"
|
||||
case "LSB":
|
||||
return "LSB"
|
||||
case "CW", "CWR":
|
||||
return "CW"
|
||||
case "AM":
|
||||
return "AM"
|
||||
case "FM", "FMN", "WFM":
|
||||
return "FM"
|
||||
case "RTTY", "RTTYR":
|
||||
return "RTTY"
|
||||
case "PKTUSB", "PKTLSB", "PKTFM", "DATA", "DIGU", "DIGL":
|
||||
return "DATA"
|
||||
default:
|
||||
return strings.ToUpper(strings.TrimSpace(mode))
|
||||
}
|
||||
}
|
||||
|
||||
func passbandFor(mode string) int {
|
||||
switch adifToHamlib(mode) {
|
||||
case "CW":
|
||||
return 500
|
||||
case "RTTY", "PKTUSB":
|
||||
return 3000
|
||||
case "AM":
|
||||
return 6000
|
||||
case "FM":
|
||||
return 15000
|
||||
default:
|
||||
return 2400
|
||||
}
|
||||
}
|
||||
|
||||
// dumpState is the capability block Hamlib clients read once at connect. WSJT-X
|
||||
// refuses to go further without it, and parses it positionally — the field
|
||||
// ORDER is the contract, so this is kept as one literal rather than assembled.
|
||||
//
|
||||
// It declares protocol version 0, a generic rig, and one 150 kHz–1500 MHz range
|
||||
// with the common modes. The numbers are deliberately permissive: they say what
|
||||
// a client may ASK for, and OpsLog's backend refuses anything the radio cannot
|
||||
// really do.
|
||||
const dumpState = `0
|
||||
1
|
||||
2
|
||||
150000.000000 1500000000.000000 0x1ff -1 -1 0x10000003 0x3
|
||||
0 0 0 0 0 0 0
|
||||
150000.000000 1500000000.000000 0x1ff -1 -1 0x10000003 0x3
|
||||
0 0 0 0 0 0 0
|
||||
0 0
|
||||
0 0
|
||||
0x1ff 1
|
||||
0x1ff 0
|
||||
0 0
|
||||
0x1e 2400
|
||||
0x2 500
|
||||
0x1 8000
|
||||
0x1 2400
|
||||
0x20 15000
|
||||
0x20 8000
|
||||
0x40 230000
|
||||
0 0
|
||||
9990
|
||||
9990
|
||||
10000
|
||||
0
|
||||
10
|
||||
10 20 30
|
||||
0x3effffff
|
||||
0x3effffff
|
||||
0x7fffffff
|
||||
0x7fffffff
|
||||
0x7fffffff
|
||||
0x7fffffff
|
||||
`
|
||||
|
||||
// dialTimeout is only used by tests, kept here so the value is one place.
|
||||
const dialTimeout = 2 * time.Second
|
||||
@@ -0,0 +1,201 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user