fix(kenwood): a busy rig is not a lost rig

A Kenwood answers "?;" while it is busy, and the TS-590SG does it for a moment
after RX; — which is exactly when the poll resumes. One rejected IF; then tore
down the whole CAT link.

The code already knew this. Its own comment says a "?;" to IF; is "a transient
busy, NOT unsupported" and that latching it off "would read as lost the rig".
That reasoning was applied to the unsupported-command map and stopped there: the
call still returned an error, the poll loop still turned it into connected=false,
and the link still dropped.

What it cost was not cosmetic. WSJT-X keys through shared CAT, so it lost the
rig mid-sequence; Hamlib then sent "F 9223372036854775808.000000" - an
uninitialised 2^63 - which OpsLog refused and logged. The operator sees a
frightening frequency error whose actual cause is three lines earlier.

A "?;" is now sentinel-wrapped so the poll can tell "the rig declined" from "the
serial link is gone", and three consecutive ones are ridden out on the last
known state before the link is called dead. Under a second of tolerance: enough
for the rig to finish, far too short to hide an unplugged cable. A serial fault
is untouched and still drops immediately.

Tested against the emulated rig already in this package, with a hook that
answers "?;" on demand: the link survives inside the grace and still reports the
last good frequency, the count resets on recovery so a later busy spell gets the
full allowance, and a rig refusing forever is still reported as broken.
This commit is contained in:
2026-08-11 08:37:24 +02:00
parent 8052bd2935
commit cfdd24d52e
2 changed files with 113 additions and 1 deletions
+73
View File
@@ -174,6 +174,19 @@ func dialTo(rig *ts2000) func() (serial.Port, error) {
}
}
// dialToBusy is dialTo with a hook that makes the rig answer "?;" instead —
// what a real Kenwood does while it is busy, rather than staying silent.
func dialToBusy(rig *ts2000, busy func(cmd string) bool) func() (serial.Port, error) {
return func() (serial.Port, error) {
return &fakeSerial{toRig: &strings.Builder{}, answer: func(cmd string) string {
if busy(cmd) {
return "?;"
}
return rig.answer(cmd)
}}, nil
}
}
func TestKenwoodAgainstEmulatedRig(t *testing.T) {
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2'} // 20 m USB
k := NewKenwood("COM-TEST", 9600, "FT8")
@@ -383,3 +396,63 @@ func TestKenwoodNoisyRigIsReportedAsNoiseNotSilence(t *testing.T) {
t.Errorf("error was %q — it should say data arrived, and quote it", err)
}
}
// A "?;" to IF; must not tear the link down.
//
// The TS-590SG answers "?;" for a moment after coming out of transmit, which is
// exactly when the poll resumes. In the field that single rejected poll dropped
// the whole CAT link: WSJT-X, keying through shared CAT, lost the rig, and
// Hamlib then sent an uninitialised frequency (2^63) that OpsLog refused — an
// alarming error whose real cause was three lines earlier.
func TestKenwoodBusyRigKeepsTheLink(t *testing.T) {
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2'}
busy := 0
k := NewKenwood("COM-TEST", 9600, "FT8")
k.dialPort = dialToBusy(rig, func(cmd string) bool {
if strings.HasPrefix(cmd, "IF") && busy > 0 {
busy--
return true
}
return false
})
if err := k.Connect(); err != nil {
t.Fatalf("connect: %v", err)
}
defer k.Disconnect()
// One good read establishes the state the busy window will keep serving.
first, err := k.ReadState()
if err != nil || !first.Connected {
t.Fatalf("first read: %+v err=%v", first, err)
}
// Inside the grace: still connected, still reporting the last good frequency.
busy = ifRejectGrace
for i := 1; i <= ifRejectGrace; i++ {
s, err := k.ReadState()
if err != nil {
t.Fatalf("reject %d dropped the link: %v", i, err)
}
if !s.Connected || s.FreqHz != first.FreqHz {
t.Errorf("reject %d gave %+v — want the last good state, still connected", i, s)
}
}
// The rig answers again → the tolerance resets, so a later busy spell gets
// the full allowance instead of inheriting the previous one.
if s, err := k.ReadState(); err != nil || !s.Connected {
t.Fatalf("recovery read failed: %+v err=%v", s, err)
}
// Past the grace, a persistent refusal IS a fault and must surface.
busy = ifRejectGrace + 1
var lastErr error
for i := 0; i <= ifRejectGrace; i++ {
if _, lastErr = k.ReadState(); lastErr != nil {
break
}
}
if lastErr == nil {
t.Error("a rig refusing IF; forever was still reported as healthy")
}
}