MSHV worked immediately, JTDX answered "Hamlib error: Invalid parameter while setting frequency". The two use different Hamlib dialects: MSHV sends "F 14074000", JTDX names the target first — "F VFOA 14074000". The VFO name landed in the slot the frequency was read from, the parse failed, and we returned RPRT -1, which is exactly the error JTDX reported. A leading VFO name is now stripped from every command's arguments. It costs nothing: OpsLog follows the rig's own VFO selection, so the name carries no information we act on — but refusing it locked out a whole family of clients. Both dialects are covered by a test, the plain one included, since this is an addition and must not become a swap. A rejected frequency is also logged with the raw line now. The client only shows "Invalid parameter", which says nothing about what it actually sent — that is why this took a screenshot to diagnose rather than a log.
425 lines
12 KiB
Go
425 lines
12 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"
|
||
"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], 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":
|
||
// 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) }
|
||
|
||
// 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
|