Listen() succeeding is not the same as being reachable. OpsLog binds 0.0.0.0, and Windows lets a second program bind the SAME port on the specific address 127.0.0.1. Connections to localhost then go to the more specific listener, so every client reaches the other program while ours sits there having logged "sharing CAT on port 4532" and never seeing a single connection. Found with Nexus, which starts its own rigctld on 127.0.0.1:4532 and talks to it. Neither side reports anything wrong: the operator gets a CAT timeout from a daemon with no radio behind it, and OpsLog's log is silent because nothing ever arrived. Three exchanges went into establishing that the connection simply never reached us — the port table was what settled it, not the code. So the server now dials its own port at startup and checks the connection lands on its own accept loop. If it does not, it says which program will be receiving the CAT connections and what to do. The counter it compares can only be raised by our own accept loop, so a real client arriving during the probe makes the check pass, never fail wrongly.
525 lines
16 KiB
Go
525 lines
16 KiB
Go
// 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"
|
||
"sync/atomic"
|
||
"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
|
||
|
||
// accepted counts every connection this listener has taken. Only selfTest
|
||
// reads it, to tell "a client reached us" from "a client reached someone
|
||
// else on our port".
|
||
accepted atomic.Int64
|
||
|
||
// ptt mirrors the last PTT state a client commanded via set_ptt. WSJT-X/JTDX
|
||
// poll get_ptt DURING transmit to confirm the rig is keyed; if get_ptt reads
|
||
// RX they conclude PTT failed and abort the over after a second or two. We
|
||
// don't read PTT back from every backend, so echo what the client last set —
|
||
// always consistent with its own command, and enough to satisfy the check.
|
||
ptt atomic.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.accepted.Add(1)
|
||
s.mu.Lock()
|
||
s.conns[c] = struct{}{}
|
||
s.mu.Unlock()
|
||
go s.serve(c)
|
||
}
|
||
}()
|
||
go s.selfTest()
|
||
return nil
|
||
}
|
||
|
||
// selfTest checks that a client connecting to 127.0.0.1:<port> actually reaches
|
||
// THIS listener, and says so in the log when it does not.
|
||
//
|
||
// Binding successfully is not the same as being reachable. OpsLog listens on
|
||
// 0.0.0.0, and Windows lets a second program bind the SAME port on the specific
|
||
// address 127.0.0.1. Connections to localhost then go to the MORE SPECIFIC
|
||
// listener — the other program — while ours sits there having logged "sharing
|
||
// CAT on port 4532" and never seeing a single client.
|
||
//
|
||
// Seen in the field with Nexus, which starts its own rigctld on 127.0.0.1:4532
|
||
// and connects to it. Neither program reports anything wrong; the operator gets
|
||
// a CAT timeout from a daemon with no radio behind it, and OpsLog's log is
|
||
// silent because nothing ever arrived. Three exchanges went into finding that,
|
||
// so it is worth one line at startup.
|
||
//
|
||
// The counter can only be raised by our own accept loop, so a real client
|
||
// arriving during the probe makes this pass, never fail wrongly.
|
||
func (s *Server) selfTest() {
|
||
before := s.accepted.Load()
|
||
addr := fmt.Sprintf("127.0.0.1:%d", s.port)
|
||
c, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||
if err != nil {
|
||
s.log("rigctld: WARNING — could not reach our own CAT port at %s (%v); "+
|
||
"clients on this PC will not find OpsLog", addr, err)
|
||
return
|
||
}
|
||
defer c.Close()
|
||
// Give the accept loop a moment: the dial returns as soon as the handshake
|
||
// completes, which can be marginally before Accept hands the connection over.
|
||
for i := 0; i < 20; i++ {
|
||
if s.accepted.Load() > before {
|
||
return // it reached us — nothing to say
|
||
}
|
||
time.Sleep(50 * time.Millisecond)
|
||
}
|
||
s.log("rigctld: WARNING — another program is already answering on %s. "+
|
||
"It will receive the CAT connections meant for OpsLog, which will look "+
|
||
"like a timeout in that program and silence here. Close it, or move "+
|
||
"OpsLog's shared CAT to a different port.", addr)
|
||
}
|
||
|
||
// releasePTT drops the transmitter when whoever was holding it goes away.
|
||
//
|
||
// Nothing else will. The Kenwood/Elecraft backend deliberately suspends its
|
||
// wire poll while PTT is held (a K3 answers "?;" to IF; during transmit), so a
|
||
// client that crashes, is killed, or simply has its socket closed under it
|
||
// leaves the rig keyed with nobody watching — and closing the socket is exactly
|
||
// what Stop() does on every settings save. Seen in the field: a K3 sat in
|
||
// transmit for 29 s, until the CAT link happened to be rebuilt.
|
||
//
|
||
// Swap makes this once-only, so the Stop() path and the per-connection defer it
|
||
// triggers can both call it without double-unkeying.
|
||
func (s *Server) releasePTT(why string) {
|
||
if !s.ptt.Swap(false) {
|
||
return
|
||
}
|
||
s.log("rigctld: %s while the rig was keyed — dropping PTT", why)
|
||
if err := s.rig.SetPTT(false); err != nil {
|
||
s.log("rigctld: emergency unkey failed: %v", err)
|
||
}
|
||
}
|
||
|
||
func (s *Server) Stop() {
|
||
// Before anything is torn down: reloadCAT calls us BEFORE it restarts the CAT
|
||
// backend, so the rig is still reachable here and an unkey still lands.
|
||
s.releasePTT("CAT sharing stopped")
|
||
|
||
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()
|
||
// A client that walks away mid-over must not leave the rig transmitting.
|
||
s.releasePTT(fmt.Sprintf("client %s left", c.RemoteAddr()))
|
||
}()
|
||
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], stripVFOArg(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 {
|
||
// Logged with the RAW line: a client that phrases a command in a
|
||
// dialect we don't accept shows only "Invalid parameter" on its side,
|
||
// which says nothing about what it actually sent.
|
||
s.log("rigctld: cannot read a frequency from %q — client dialect not handled", line)
|
||
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":
|
||
// Echo the last commanded PTT state. WSJT-X/JTDX poll this WHILE
|
||
// transmitting to confirm the rig is keyed; answering a blanket "0" (RX)
|
||
// made them decide PTT had failed and abort the over after ~1-2 s.
|
||
if s.ptt.Load() {
|
||
return "1\n", false
|
||
}
|
||
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
|
||
}
|
||
s.ptt.Store(on)
|
||
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:
|
||
// A frame ending in ';' is not a rigctl command at all — it is raw rig
|
||
// dialect (Kenwood/Elecraft/Yaesu), which means the client is configured
|
||
// with a RIG MODEL pointing at this port instead of "Hamlib NET rigctl".
|
||
//
|
||
// Worth naming, because the symptom hides the cause completely: we answer
|
||
// RPRT -11 like any unknown command, but that reply has no ';' to terminate
|
||
// on, so the client's parser waits and then reports "reply incomplete, got
|
||
// nothing". The operator sees a timeout and concludes the CAT share is
|
||
// broken, when it is a one-line setting in the other program.
|
||
if strings.HasSuffix(line, ";") && !strings.Contains(line, " ") {
|
||
s.log("rigctld: %q is a raw rig command, not rigctl — the client is set to a RIG MODEL; "+
|
||
"it must be set to \"Hamlib NET rigctl\" (rig 2) at this address", line)
|
||
return rprt(-11), false
|
||
}
|
||
// 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) }
|
||
|
||
// stripVFOArg drops a leading VFO name from a command's arguments.
|
||
//
|
||
// Hamlib has two dialects. In the plain one a client sends "F 14074000"; in VFO
|
||
// mode it names the target first — "F VFOA 14074000". MSHV uses the first and
|
||
// worked immediately; JTDX uses the second, so the frequency landed in the
|
||
// argument slot where a VFO was expected, the parse failed, and JTDX showed
|
||
// "Hamlib error: Invalid parameter while setting frequency" (our RPRT -1).
|
||
//
|
||
// Accepting both costs nothing here: OpsLog follows the rig's own VFO
|
||
// selection, so the name carries no information we act on — dropping it is not
|
||
// losing anything, and refusing it locks out a whole family of clients.
|
||
func stripVFOArg(args []string) []string {
|
||
if len(args) == 0 {
|
||
return args
|
||
}
|
||
switch strings.ToUpper(args[0]) {
|
||
case "VFOA", "VFOB", "VFOC", "VFO", "CURRVFO", "CURR", "MAIN", "SUB", "MEM", "A", "B":
|
||
return args[1:]
|
||
}
|
||
return args
|
||
}
|
||
|
||
// 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
|