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
+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