// 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