fix(catshare): stop FT8 dial drift in shared CAT (rigctld freq echo)

A digital app (JTDX/WSJT-X) sharing OpsLog's rig in "Fake It" split follows
the dial by polling get_freq. Freq() is the last polled value and lags a
set_freq by a poll cycle, so right after a transmission the client read the
still-shifted frequency, mistook it for a manual QSY and adopted it — the dial
crept down every over and never came back. get_freq now echoes the last
commanded frequency until the rig confirms it (or a short deadline passes),
closing the race. Backend-agnostic, so it fixes every rig, not just Kenwood.
This commit is contained in:
2026-08-04 23:02:44 +02:00
parent f27faa81a6
commit ede81d3926
3 changed files with 115 additions and 3 deletions
+47 -2
View File
@@ -59,8 +59,28 @@ type Server struct {
ln net.Listener
conns map[net.Conn]struct{}
closed bool
// Optimistic frequency echo. A sharing client that follows our dial by
// polling get_freq (WSJT-X / JTDX in "Fake It" split) shifts the frequency on
// TX and restores it on RX. Freq() is the last value POLLED from the rig, and
// it lags a set_freq by up to a poll cycle (~100-200 ms). In that window the
// client — no longer transmitting — reads back the still-shifted frequency,
// mistakes it for a manual QSY and adopts it, so every over creeps the dial by
// the shift amount and it never comes back. Echoing the last commanded
// frequency until the rig confirms it (or a short deadline passes) closes the
// window: the client always reads exactly what it just set.
echoMu sync.Mutex
echoHz int64
echoAt time.Time
echoWant bool
}
// freqEchoTTL caps how long a commanded frequency is echoed when the rig never
// reports it back (e.g. it rounded to a coarser step). Long enough to cover a
// poll cycle with margin, short enough that a genuine knob turn during the
// window surfaces quickly.
const freqEchoTTL = 2 * time.Second
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
@@ -193,7 +213,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
return "", true
case "f", "\\get_freq":
return fmt.Sprintf("%d\n", s.rig.Freq()), false
return fmt.Sprintf("%d\n", s.reportedFreq()), false
case "F", "\\set_freq":
if len(args) < 1 {
return rprt(-1), false
@@ -210,6 +230,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
s.log("rigctld: set_freq %d failed: %v", hz, err)
return rprt(-9), false
}
s.noteSetFreq(hz)
return rprt(0), false
case "m", "\\get_mode":
@@ -263,7 +284,7 @@ func (s *Server) handle(line string) (resp string, quit bool) {
case "i", "\\get_split_freq":
_, tx := s.rig.Split()
if tx <= 0 {
tx = s.rig.Freq()
tx = s.reportedFreq()
}
return fmt.Sprintf("%d\n", tx), false
case "I", "\\set_split_freq":
@@ -279,6 +300,30 @@ func (s *Server) handle(line string) (resp string, quit bool) {
func rprt(code int) string { return fmt.Sprintf("RPRT %d\n", code) }
// noteSetFreq records a frequency a client just commanded, so the next reads
// echo it back until the rig confirms the tune.
func (s *Server) noteSetFreq(hz int64) {
s.echoMu.Lock()
s.echoHz, s.echoAt, s.echoWant = hz, time.Now(), true
s.echoMu.Unlock()
}
// reportedFreq is what get_freq answers: the last commanded frequency while the
// rig is still catching up to it, otherwise the live polled value. See echoWant.
func (s *Server) reportedFreq() int64 {
live := s.rig.Freq()
s.echoMu.Lock()
defer s.echoMu.Unlock()
if s.echoWant {
if live == s.echoHz || time.Since(s.echoAt) > freqEchoTTL {
s.echoWant = false // rig confirmed the tune, or we waited long enough
return live
}
return s.echoHz
}
return live
}
// 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
+58 -1
View File
@@ -20,6 +20,7 @@ type fakeRig struct {
setFreqs []int64
setModes []string
failSet bool
lagSet bool // record the command but do not move freq — simulate poll lag
}
func (f *fakeRig) Freq() int64 { f.mu.Lock(); defer f.mu.Unlock(); return f.freq }
@@ -31,8 +32,10 @@ func (f *fakeRig) SetFreq(hz int64) error {
if f.failSet {
return fmt.Errorf("rig refused")
}
f.freq = hz
f.setFreqs = append(f.setFreqs, hz)
if !f.lagSet {
f.freq = hz
}
return nil
}
func (f *fakeRig) SetMode(m string) error {
@@ -138,6 +141,60 @@ func TestHandleReportsBackendFailure(t *testing.T) {
}
}
// The FT8 "Fake It" drift. JTDX/WSJT-X shift the dial down on TX and restore it
// on RX, and they follow the dial by polling get_freq. Our Freq() is the last
// POLLED value and lags a set_freq by a poll cycle, so right after the restore
// the client used to read the still-shifted frequency, take it for a manual QSY
// and adopt it — the dial crept down every over and never came back. get_freq
// now echoes the last commanded frequency until the rig confirms it.
func TestFakeItSplitDoesNotDriftTheDial(t *testing.T) {
// lagSet: SetFreq only records the command; we drive the poll catch-up by hand
// to land inside the exact window the drift lived in.
rig := &fakeRig{freq: 14074000, lagSet: true}
s := New(0, rig, nil)
if got, _ := s.handle("f"); got != "14074000\n" {
t.Fatalf("baseline get_freq = %q, want 14074000", got)
}
setFreq := func(hz int64) { rig.mu.Lock(); rig.freq = hz; rig.mu.Unlock() }
for cycle := 0; cycle < 5; cycle++ {
// TX: the client shifts the dial down for the over.
if got, _ := s.handle("F 14073500"); got != "RPRT 0\n" {
t.Fatalf("cycle %d TX set_freq = %q", cycle, got)
}
// Mid-TX, before the rig reports the move, the client reads back exactly
// what it commanded — not the stale 14074000.
if got, _ := s.handle("f"); got != "14073500\n" {
t.Fatalf("cycle %d TX get_freq = %q, want commanded 14073500", cycle, got)
}
setFreq(14073500) // poll catches up to the shifted dial
// RX: the client restores the dial.
if got, _ := s.handle("F 14074000"); got != "RPRT 0\n" {
t.Fatalf("cycle %d RX set_freq = %q", cycle, got)
}
// The rig has not yet reported the restore (still 14073500). Before the fix
// this returned 14073500 and JTDX adopted it — the cumulative drift. Now it
// echoes the restore, so the dial holds.
if got, _ := s.handle("f"); got != "14074000\n" {
t.Fatalf("cycle %d RX get_freq = %q, want restored 14074000 — dial drifted", cycle, got)
}
setFreq(14074000) // poll catches up to the restored dial
}
// Once a poll confirms the commanded dial, the echo is released (in the field
// the client polls continuously, so this happens within a cycle).
if got, _ := s.handle("f"); got != "14074000\n" {
t.Fatalf("confirming get_freq = %q, want 14074000", got)
}
// A genuine knob turn now surfaces at once.
setFreq(14075000)
if got, _ := s.handle("f"); got != "14075000\n" {
t.Errorf("manual QSY get_freq = %q, want 14075000 — echo hid a real move", got)
}
}
// dump_state is parsed POSITIONALLY by Hamlib clients: WSJT-X reads the first
// line as the protocol version and refuses to continue if the block is short or
// misshapen. Pinning its shape is what stops a well-meaning edit from silently