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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user