set_split_vfo and set_split_freq both answered RPRT 0 and did nothing. WSJT-X and JTDX in "Split Operating: Rig" send exactly that pair, believed both, and transmitted on the RECEIVE frequency — on a pileup, straight onto the DX, while showing the operator precisely what they had asked for. A lie that leaves no trace in any log is the worst kind of bug this program can have. The two commands are honoured as a PAIR. Arming alone does nothing on the radio, because WSJT-X sends the frequency second and split armed on whatever the transmit VFO happened to hold is worse than no split at all: it transmits somewhere the operator never chose. The request is remembered and set_split_freq does the work. Kenwood gains SetSplit — FB to place the dial, then FR0/FT1 to arm, in that order for the same reason. It writes what State() already knows how to read. Everything else REFUSES, and that is the feature, not a shortfall. Only Flex and Icom could even toggle split before, neither could set the transmit frequency, and Yaesu, TCI and OmniRig have nothing at all. A refusal WSJT-X can report — and act on, by falling back to Fake It — is worth far more than a success it has no way to check. Both paths are pinned: split reaching the rig as one armed call with the right frequency, and a backend that cannot do it producing an error rather than RPRT 0.
608 lines
20 KiB
Go
608 lines
20 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
|
||
// SetSplit arms or clears split and places the transmit frequency. Returns an
|
||
// error on a rig that cannot: a refusal the client can report is worth far
|
||
// more than a success it has no way to check.
|
||
SetSplit(on bool, txHz int64) 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
|
||
// pttKnown says ptt reflects a state we actually commanded, so a repeat can
|
||
// be told from the very first call — where the radio's state is unknown and
|
||
// the command must go through.
|
||
pttKnown atomic.Bool
|
||
// splitWanted remembers a set_split_vfo that arrived before the frequency it
|
||
// needs, so the pair can be honoured in the order the client sends them.
|
||
splitWanted 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
|
||
|
||
// Three commands a client may issue as a matter of course. Refusing them with
|
||
// RPRT -11 is allowed, and a tolerant client carries on — but nothing obliges
|
||
// it to, and Nexus sends all three around every transmit. Answering costs
|
||
// nothing and removes them as suspects when something really is wrong.
|
||
case "\\get_lock_mode", "get_lock_mode":
|
||
// Truthful: OpsLog never locks the dial against its own clients.
|
||
return "0\n", false
|
||
case "\\set_lock_mode", "set_lock_mode":
|
||
// Accepted and ignored, like set_vfo below: there is no lock to set, and
|
||
// failing here would abort a client's whole transmit sequence over a
|
||
// setting that has no effect either way.
|
||
return rprt(0), false
|
||
case "\\stop_morse", "stop_morse":
|
||
// Nothing is queued here — CW over CAT is keyed by the rig's own keyer
|
||
// through the backend, not buffered in this server. "Stopped" is therefore
|
||
// accurate rather than polite.
|
||
return rprt(0), 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"
|
||
// Only touch the radio on a CHANGE.
|
||
//
|
||
// A client is free to restate PTT as often as it likes, and one does:
|
||
// Nexus sends set_ptt 0 about sixteen times a second, so the Flex was
|
||
// getting "xmit 0" every 60 ms forever. Worse than wasteful — its own
|
||
// "xmit 1" landed between two of them and was overwritten in the same
|
||
// millisecond, so the radio never stayed keyed and the operator saw a
|
||
// transmit request that simply did nothing.
|
||
//
|
||
// Repeating a state is not a request to change it. The first call always
|
||
// goes through, since we cannot know how the radio was left.
|
||
if s.pttKnown.Load() && s.ptt.Load() == on {
|
||
return rprt(0), false
|
||
}
|
||
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)
|
||
s.pttKnown.Store(true)
|
||
s.log("rigctld: PTT %s", map[bool]string{true: "ON", false: "off"}[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":
|
||
// "S <0|1> <VFO>". The VFO argument is ignored: which dial transmits is the
|
||
// rig's own business, and every backend here puts it on the second one.
|
||
//
|
||
// This used to answer RPRT 0 and do NOTHING. WSJT-X in "Split Operating:
|
||
// Rig" sends this and set_split_freq, believed both, and transmitted on the
|
||
// RECEIVE frequency — on a pileup, straight onto the DX, while the software
|
||
// showed exactly what the operator had asked for. A lie that leaves no
|
||
// trace anywhere is the worst kind of bug, so it now works or says so.
|
||
if len(args) < 1 {
|
||
return rprt(-1), false
|
||
}
|
||
if args[0] != "0" {
|
||
// Arming needs a frequency, and WSJT-X sends set_split_freq AFTER this.
|
||
// Remember the request and let that command do the work: alone, this
|
||
// would arm split on whatever the transmit VFO happens to hold.
|
||
s.splitWanted.Store(true)
|
||
return rprt(0), false
|
||
}
|
||
s.splitWanted.Store(false)
|
||
if err := s.rig.SetSplit(false, 0); err != nil {
|
||
s.log("rigctld: split off failed: %v", err)
|
||
return rprt(-9), false
|
||
}
|
||
s.log("rigctld: split off")
|
||
return rprt(0), false
|
||
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":
|
||
if len(args) < 1 {
|
||
return rprt(-1), false
|
||
}
|
||
// Hamlib sends a float ("14075300.000000"), so parse as one.
|
||
hz, err := strconv.ParseFloat(args[0], 64)
|
||
if err != nil || hz <= 0 {
|
||
return rprt(-1), false
|
||
}
|
||
if err := s.rig.SetSplit(true, int64(hz)); err != nil {
|
||
s.log("rigctld: split TX %.0f Hz failed: %v", hz, err)
|
||
return rprt(-9), false
|
||
}
|
||
s.splitWanted.Store(true)
|
||
s.log("rigctld: split ON, TX %.0f Hz", hz)
|
||
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
|