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
+8
View File
@@ -16930,6 +16930,14 @@ func (r catShareRig) SetFreq(hz int64) error { return r.a.cat.SetFrequency(hz) }
func (r catShareRig) SetMode(m string) error { return r.a.cat.SetMode(m) }
func (r catShareRig) SetPTT(on bool) error { return r.a.cat.SetPTT(on) }
// SetSplit passes the client's split request through to the radio, and passes
// the refusal back when the backend cannot do it. That refusal is the feature:
// WSJT-X can tell the operator to use "Fake It" instead, where before it was
// told everything had worked and transmitted on the receive frequency.
func (r catShareRig) SetSplit(on bool, txHz int64) error {
return r.a.cat.SetSplit(on, txHz)
}
// reloadCATShare starts, stops or restarts the sharing server to match the
// settings. Called from reloadCAT so one "Save & Close" settles both.
func (a *App) reloadCATShare(s CATSettings) {
+4 -2
View File
@@ -11,7 +11,8 @@
"Test connection now actually tests. Club Log checked that the three fields were not empty and reported success without contacting anyone, so a wrong password looked exactly like a right one; it now signs in for real, through the read-only endpoint so a test can never add a record. LoTW reports its two credentials separately: TQSL signs uploads and never uses the website password, so a wrong one used to break nothing until the day confirmations were downloaded.",
"The Ultrabeam / SteppIR widget in Station Control can now drive the antenna, not just describe it: a button per configured band tunes it there, up and down move it 25 kHz at a time, and tracking can be switched on or off with its 25 / 50 / 100 kHz threshold beside it. The band you are on lights up, and the commanded frequency is shown in the header.",
"The QSO filter can express a real question. Every condition was joined by one global AND or OR, so \"2 m or 70 cm, in FT8, since January\" had no form: AND killed the two bands, OR let every FT8 QSO through. Two operators — is one of / is none of — take a comma-separated list, so the OR sits inside one condition and the rest keeps ANDing.",
"Kenwood in split: the transmit and receive frequencies no longer swap the moment you key up. The rig reports the VFO in use, which is the receive one on receive and the transmit one on transmit; OpsLog took it as the receive VFO always. It showed on the display, and it decided which frequency a split QSO was logged on."
"Kenwood in split: the transmit and receive frequencies no longer swap the moment you key up. The rig reports the VFO in use, which is the receive one on receive and the transmit one on transmit; OpsLog took it as the receive VFO always. It showed on the display, and it decided which frequency a split QSO was logged on.",
"Split Operating: Rig now works through the shared CAT, on Kenwood. WSJT-X and JTDX send two commands to arm split and place the transmit frequency; OpsLog answered \"done\" to both and did nothing, so the software believed it was transmitting up the band while the radio stayed on the receive frequency — on a pileup, straight onto the DX. A radio whose backend cannot set split now REFUSES, so WSJT-X says so and you can switch to Fake It, instead of being told all was well."
],
"fr": [
"Décodes digitaux : le carré locator d une station pouvait être enregistré comme son indicatif quand le texte du message sortait de l ordinaire. Un grid à la place de l indicatif est maintenant refusé.",
@@ -22,7 +23,8 @@
"Le bouton Tester la connexion teste vraiment. Club Log vérifiait que les trois champs n étaient pas vides et annonçait la réussite sans contacter personne : un mauvais mot de passe ressemblait exactement à un bon. Il s authentifie maintenant pour de vrai, via le point d accès en lecture seule pour qu un test ne puisse jamais ajouter un enregistrement. LoTW annonce ses deux identifiants séparément : TQSL signe les envois et n utilise jamais le mot de passe du site, donc un mauvais ne cassait rien jusqu au jour du téléchargement des confirmations.",
"Le widget Ultrabeam / SteppIR de Station Control pilote enfin l antenne au lieu de seulement la décrire : un bouton par bande configurée l accorde dessus, haut et bas la déplacent par pas de 25 kHz, et le suivi s active ou se coupe avec son seuil de 25 / 50 / 100 kHz à côté. La bande courante s allume, et la fréquence commandée s affiche dans l en-tête.",
"Le filtre des QSO sait exprimer une vraie question. Toutes les conditions étaient jointes par un seul ET ou OU global, donc « 2 m ou 70 cm, en FT8, depuis janvier » n avait aucune forme : le ET tuait les deux bandes, le OU laissait passer tous les QSO FT8. Deux opérateurs — est parmi / n est pas parmi — acceptent une liste séparée par des virgules, le OU tient donc dans une seule condition et le reste continue de se combiner en ET.",
"Kenwood en split : les fréquences d émission et de réception ne s inversent plus dès qu on passe en émission. Le rig annonce le VFO en service, qui est celui de réception à la réception et celui d émission à l émission ; OpsLog le prenait toujours pour celui de réception. Ça se voyait à l affichage, et ça décidait surtout sur quelle fréquence un QSO en split était enregistré."
"Kenwood en split : les fréquences d émission et de réception ne s inversent plus dès qu on passe en émission. Le rig annonce le VFO en service, qui est celui de réception à la réception et celui d émission à l émission ; OpsLog le prenait toujours pour celui de réception. Ça se voyait à l affichage, et ça décidait surtout sur quelle fréquence un QSO en split était enregistré.",
"Split Operating: Rig fonctionne enfin via le CAT partagé, sur Kenwood. WSJT-X et JTDX envoient deux commandes pour armer le split et poser la fréquence d émission ; OpsLog répondait « fait » aux deux sans rien faire, donc le logiciel croyait émettre plus haut alors que la radio restait sur la fréquence de réception — sur un pileup, en plein sur le DX. Une radio dont le backend ne sait pas faire le split REFUSE désormais, WSJT-X le dit et tu peux passer en Fake It, au lieu qu on t affirme que tout va bien."
]
},
{
+25
View File
@@ -200,6 +200,31 @@ func (m *Manager) SetPTT(on bool) error {
return m.exec(func(b Backend) error { return b.SetPTT(on) })
}
// splitSetter is implemented by the backends that can arm split AND place the
// transmit frequency. Both together: arming without setting the dial transmits
// on whatever the transmit VFO happened to hold, which is worse than refusing.
type splitSetter interface {
SetSplit(on bool, txHz int64) error
}
// SetSplit arms or clears split on the rig, with the transmit frequency.
//
// Returns a plain error on a backend that cannot do it, and that is the point.
// The shared-CAT server used to answer "done" to WSJT-X's split commands while
// doing nothing at all — the software then believed it was transmitting up the
// band when it was transmitting on the DX's own frequency. A refusal WSJT-X can
// report is worth far more than a success it cannot check.
func (m *Manager) SetSplit(on bool, txHz int64) error {
return m.exec(func(b Backend) error {
s, ok := b.(splitSetter)
if !ok {
return fmt.Errorf("cat: this radio's backend cannot set split from software — " +
"use Split Operating: Fake It in WSJT-X/JTDX, or set split on the radio itself")
}
return s.SetSplit(on, txHz)
})
}
// SpotInfo is one cluster spot to render on a backend that supports a spot
// overlay (the FlexRadio panadapter). Color is an optional "#AARRGGBB" string;
// the backend picks a default when it's empty. (Status-based colouring can be
+3 -3
View File
@@ -59,15 +59,15 @@ type Flex struct {
meterRawLogged bool // log the first raw meter-definition status once
txRawLogged bool // log the first raw transmit status once (field-name audit)
spotsEnabled bool // push cluster spots + manage the panadapter overlay
spotIdx map[int]bool // panadapter spot indices currently known to the radio
spotsEnabled bool // push cluster spots + manage the panadapter overlay
spotIdx map[int]bool // panadapter spot indices currently known to the radio
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
// OnSpotClick is called (off the reader goroutine's hot path) when the user
// clicks one of our spots on the panadapter, with the spot's callsign and
+37
View File
@@ -513,6 +513,43 @@ func isKenwoodDataMode(mode string) bool {
return true
}
// SetSplit arms or clears split, and when arming puts txHz on the transmit VFO.
//
// Both halves in one call on purpose. WSJT-X sends "split on, VFO B" and "VFO B
// to 14075300" as two commands, and honouring only the first is worse than
// honouring neither: split would arm on whatever VFO B happened to hold, so the
// operator transmits somewhere they never chose while the software reports
// exactly what they asked for. Nothing is armed here until the frequency is on
// the dial.
//
// FR selects the receive VFO, FT the transmit one — the same pair the poll loop
// already reads to detect split, so this writes what State() knows how to read.
func (k *Kenwood) SetSplit(on bool, txHz int64) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
if !on {
// Transmit follows receive again. FR is left alone: which VFO the operator
// listens on is theirs to choose, and clearing split should not move them.
return k.write("FT0;")
}
if txHz <= 0 || txHz > 99_999_999_999 {
return fmt.Errorf("kenwood: split TX frequency %d out of the 11-digit CAT range", txHz)
}
// The transmit dial FIRST, then arm. Arming first would transmit on the old
// contents of VFO B for however long the next command takes to arrive — brief,
// but on the wrong frequency, and this runs the instant before a transmission.
if err := k.write(fmt.Sprintf("FB%011d;", txHz)); err != nil {
return err
}
if err := k.write("FR0;"); err != nil { // receive on A
return err
}
return k.write("FT1;") // transmit on B
}
func (k *Kenwood) SetPTT(on bool) error {
k.mu.Lock()
defer k.mu.Unlock()
+1 -1
View File
@@ -102,7 +102,7 @@ func TestKenwoodModeDigit(t *testing.T) {
{"FM", 145000000, '4'},
{"FT8", 7074000, '2'}, // data is ALWAYS USB, even below 10 MHz (K3 "DATA REV" otherwise)
{"FT8", 14074000, '2'}, // …and above
{"", 14074000, 0}, // nothing to set
{"", 14074000, 0}, // nothing to set
}
for _, c := range cases {
if got := kenwoodModeDigit(c.mode, c.hz); got != c.want {
+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)
}
}