feat(rigctld): split that actually reaches the radio, or an honest refusal

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.
This commit is contained in:
2026-08-11 17:53:46 +02:00
parent fa01968207
commit 43095a1d89
9 changed files with 192 additions and 7 deletions
+46 -1
View File
@@ -49,6 +49,10 @@ type Rig interface {
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 {
@@ -76,6 +80,9 @@ type Server struct {
// 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 {
@@ -383,7 +390,31 @@ func (s *Server) handle(line string) (resp string, quit bool) {
}
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
// "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 {
@@ -391,6 +422,20 @@ func (s *Server) handle(line string) (resp string, quit bool) {
}
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:
+16
View File
@@ -2,6 +2,7 @@ package rigctld
import (
"bufio"
"errors"
"fmt"
"net"
"strings"
@@ -21,11 +22,26 @@ type fakeRig struct {
setFreqs []int64
setModes []string
failSet bool
// noSplit models a backend that cannot set split — the case that must reach
// the client as a refusal instead of a silent success.
noSplit bool
splitCalls []string
}
func (f *fakeRig) Freq() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.freq }
func (f *fakeRig) Mode() string { f.mu.Lock(); defer f.mu.Unlock(); return f.mode }
func (f *fakeRig) Split() (bool, int64) { f.mu.Lock(); defer f.mu.Unlock(); return f.split, f.txFreq }
func (f *fakeRig) SetSplit(on bool, txHz int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.noSplit {
return errors.New("this radio cannot set split from software")
}
f.splitCalls = append(f.splitCalls, fmt.Sprintf("%v:%d", on, txHz))
f.split, f.txFreq = on, txHz
return nil
}
func (f *fakeRig) SetFreq(hz int64) error {
f.mu.Lock()
defer f.mu.Unlock()
+52
View File
@@ -0,0 +1,52 @@
package rigctld
import (
"strings"
"testing"
)
// WSJT-X in "Split Operating: Rig" sends set_split_vfo then set_split_freq. Both
// used to answer RPRT 0 and do NOTHING: the software believed it was
// transmitting up the band while the radio stayed on the receive frequency —
// on a pileup, straight onto the DX, with no trace anywhere.
func TestSetSplitReachesTheRig(t *testing.T) {
rig := &fakeRig{freq: 14074000, mode: "FT8"}
s := New(0, rig, func(string, ...any) {})
if got, _ := s.handle("S 1 VFOB"); !strings.HasPrefix(got, "RPRT 0") {
t.Fatalf("set_split_vfo answered %q", got)
}
// Arming alone must NOT touch the rig: without a frequency it would transmit
// on whatever the second VFO happened to hold.
if len(rig.splitCalls) != 0 {
t.Errorf("split was armed before a frequency arrived: %v", rig.splitCalls)
}
if got, _ := s.handle("I 14075300.000000"); !strings.HasPrefix(got, "RPRT 0") {
t.Fatalf("set_split_freq answered %q", got)
}
if len(rig.splitCalls) != 1 || rig.splitCalls[0] != "true:14075300" {
t.Fatalf("rig saw %v, want one call arming split on 14075300", rig.splitCalls)
}
if got, _ := s.handle("S 0 VFOA"); !strings.HasPrefix(got, "RPRT 0") {
t.Fatalf("split off answered %q", got)
}
if len(rig.splitCalls) != 2 || !strings.HasPrefix(rig.splitCalls[1], "false:") {
t.Errorf("rig saw %v, want split cleared", rig.splitCalls)
}
}
// A backend that cannot do split must produce an ERROR the client can report.
// Answering success and doing nothing is what caused the original fault, and it
// is the one outcome that must never come back.
func TestSetSplitRefusalIsReported(t *testing.T) {
rig := &fakeRig{freq: 14074000, noSplit: true}
s := New(0, rig, func(string, ...any) {})
s.handle("S 1 VFOB")
got, _ := s.handle("I 14075300.000000")
if strings.HasPrefix(got, "RPRT 0") {
t.Errorf("a rig that cannot split answered %q — the client will transmit on the wrong frequency", got)
}
}