Files
OpsLog/internal/rigctld/rigctld.go
T
rouggy 753d8d2ffa fix: accept Hamlib's VFO-prefixed commands — JTDX could not set the frequency
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.
2026-07-29 10:58:16 +02:00

425 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 kHz1500 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