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.
459 lines
15 KiB
Go
459 lines
15 KiB
Go
package cat
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.bug.st/serial"
|
|
)
|
|
|
|
// The Kenwood backend driven against a rig that answers, with no hardware.
|
|
//
|
|
// Nobody here owns a Kenwood, and a backend that has never completed a single
|
|
// exchange is a guess however carefully it was written. But this repository
|
|
// already contains the other half of the conversation: internal/catemu ANSWERS
|
|
// this dialect, pretending to be a TS-2000 so an ACOM amplifier follows OpsLog.
|
|
// The responder below replies exactly as catemu does — same FA/FB widths, same
|
|
// 38-character IF layout, same ID019 — so the two halves are checked against
|
|
// each other rather than against my reading of a manual.
|
|
//
|
|
// What this proves: the round trip completes, the model is identified, and
|
|
// frequency, mode, VFO and split come back correct, including the second read
|
|
// that split requires. What it cannot prove: that a real TS-590 answers on the
|
|
// same timings, or that its firmware fills every IF field the way the
|
|
// documentation says. Those still need a radio.
|
|
|
|
// fakeSerial is one end of an in-memory serial link. Only Read and Write carry
|
|
// meaning; the rest satisfy the interface.
|
|
type fakeSerial struct {
|
|
mu sync.Mutex
|
|
toRig *strings.Builder // what the backend has written
|
|
fromRig []byte // what the rig has queued for the backend
|
|
answer func(cmd string) string
|
|
closed bool
|
|
}
|
|
|
|
func (f *fakeSerial) Write(p []byte) (int, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.closed {
|
|
return 0, fmt.Errorf("closed")
|
|
}
|
|
f.toRig.Write(p)
|
|
// A rig answers as each terminated command arrives.
|
|
for {
|
|
s := f.toRig.String()
|
|
i := strings.IndexByte(s, ';')
|
|
if i < 0 {
|
|
break
|
|
}
|
|
cmd := s[:i+1]
|
|
rest := s[i+1:]
|
|
f.toRig.Reset()
|
|
f.toRig.WriteString(rest)
|
|
if r := f.answer(cmd); r != "" {
|
|
f.fromRig = append(f.fromRig, r...)
|
|
}
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func (f *fakeSerial) Read(p []byte) (int, error) {
|
|
// A real port blocks until data or timeout; the backend polls, so returning
|
|
// (0, nil) on an empty buffer models a read timeout with no bytes.
|
|
for i := 0; i < 50; i++ {
|
|
f.mu.Lock()
|
|
if f.closed {
|
|
f.mu.Unlock()
|
|
return 0, fmt.Errorf("closed")
|
|
}
|
|
if len(f.fromRig) > 0 {
|
|
n := copy(p, f.fromRig)
|
|
f.fromRig = f.fromRig[n:]
|
|
f.mu.Unlock()
|
|
return n, nil
|
|
}
|
|
f.mu.Unlock()
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
func (f *fakeSerial) Close() error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.closed = true
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeSerial) SetMode(*serial.Mode) error { return nil }
|
|
func (f *fakeSerial) Drain() error { return nil }
|
|
func (f *fakeSerial) ResetInputBuffer() error { return nil }
|
|
func (f *fakeSerial) ResetOutputBuffer() error { return nil }
|
|
func (f *fakeSerial) SetDTR(bool) error { return nil }
|
|
func (f *fakeSerial) SetRTS(bool) error { return nil }
|
|
func (f *fakeSerial) GetModemStatusBits() (*serial.ModemStatusBits, error) {
|
|
return &serial.ModemStatusBits{}, nil
|
|
}
|
|
func (f *fakeSerial) SetReadTimeout(time.Duration) error { return nil }
|
|
func (f *fakeSerial) Break(time.Duration) error { return nil }
|
|
|
|
// ts2000 answers as internal/catemu does. vfoA/vfoB are the two dials; mode is
|
|
// the Kenwood digit; split selects which VFO transmits.
|
|
type ts2000 struct {
|
|
vfoA, vfoB int64
|
|
mode byte
|
|
onB bool
|
|
split bool
|
|
// lazyIF models a rig that answers FR/FT correctly but never fills IF's
|
|
// split bit — the behaviour reported on a Flex through its Kenwood CAT
|
|
// emulation, where the frequency reads perfectly and split never appears.
|
|
lazyIF bool
|
|
// noVFOCmds models a rig that rejects FR/FT outright ("?;").
|
|
noVFOCmds bool
|
|
seen []string
|
|
}
|
|
|
|
func (r *ts2000) answer(cmd string) string {
|
|
r.seen = append(r.seen, cmd)
|
|
cur := r.vfoA
|
|
vfoDigit := byte('0')
|
|
if r.onB {
|
|
cur, vfoDigit = r.vfoB, '1'
|
|
}
|
|
switch {
|
|
case cmd == "ID;":
|
|
return "ID019;" // TS-2000, exactly what catemu reports
|
|
case cmd == "FA;":
|
|
return fmt.Sprintf("FA%011d;", r.vfoA)
|
|
case cmd == "FB;":
|
|
return fmt.Sprintf("FB%011d;", r.vfoB)
|
|
case cmd == "IF;":
|
|
split := byte('0')
|
|
if r.split && !r.lazyIF {
|
|
split = '1'
|
|
}
|
|
// The catemu layout: IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) |
|
|
// rx/tx | mode | VFO | scan | split | tone | tone#(2) | shift | ;
|
|
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%c%01d%c%01d%02d%01d;",
|
|
cur, 0, 0, 0, 0, 0, r.mode, vfoDigit, 0, split, 0, 0, 0)
|
|
case strings.HasPrefix(cmd, "FA") && len(cmd) > 3:
|
|
fmt.Sscanf(cmd, "FA%d;", &r.vfoA)
|
|
return ""
|
|
case strings.HasPrefix(cmd, "FB") && len(cmd) > 3:
|
|
fmt.Sscanf(cmd, "FB%d;", &r.vfoB)
|
|
return ""
|
|
case strings.HasPrefix(cmd, "MD") && len(cmd) == 4:
|
|
r.mode = cmd[2]
|
|
return ""
|
|
case cmd == "FR;" || cmd == "FT;":
|
|
if r.noVFOCmds {
|
|
return "?;" // rejected, as a rig that does not know the command answers
|
|
}
|
|
// FR is the receive VFO, FT the transmit one. They differ exactly when
|
|
// the rig is in split.
|
|
v := vfoDigit
|
|
if cmd == "FT;" && r.split {
|
|
if vfoDigit == '0' {
|
|
v = '1'
|
|
} else {
|
|
v = '0'
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s%c;", strings.TrimSuffix(cmd, ";"), v)
|
|
}
|
|
return "" // AI0;, TX;, RX; — set commands, no reply, as on a real rig
|
|
}
|
|
|
|
func dialTo(rig *ts2000) func() (serial.Port, error) {
|
|
return func() (serial.Port, error) {
|
|
return &fakeSerial{toRig: &strings.Builder{}, answer: rig.answer}, nil
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
k.dialPort = dialTo(rig)
|
|
|
|
if err := k.Connect(); err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer k.Disconnect()
|
|
if k.model != "TS-2000" {
|
|
t.Errorf("model = %q, want TS-2000 (from ID019)", k.model)
|
|
}
|
|
|
|
// Simplex on A.
|
|
s, err := k.ReadState()
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if !s.Connected || s.FreqHz != 14250000 || s.Mode != "USB" || s.Vfo != "A" || s.Split {
|
|
t.Errorf("simplex A gave %+v — want 14250000 USB on A, no split", s)
|
|
}
|
|
|
|
// On B: everything must follow the VFO in use, the fault that took several
|
|
// rounds to settle in the Yaesu backend.
|
|
rig.onB = true
|
|
if s, err = k.ReadState(); err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if s.FreqHz != 14260000 || s.Vfo != "B" {
|
|
t.Errorf("on B gave %d on VFO %s — want 14260000 on B", s.FreqHz, s.Vfo)
|
|
}
|
|
|
|
// Tuning must write to the VFO in use, not blindly to FA.
|
|
if err := k.SetFrequency(14265000); err != nil {
|
|
t.Fatalf("set freq: %v", err)
|
|
}
|
|
if rig.vfoB != 14265000 {
|
|
t.Errorf("VFO B = %d, want 14265000 — the write went to the wrong VFO", rig.vfoB)
|
|
}
|
|
if rig.vfoA != 14250000 {
|
|
t.Errorf("VFO A was disturbed: %d", rig.vfoA)
|
|
}
|
|
|
|
// Split: receive on B, transmit on A. ADIF says FREQ is the TRANSMIT
|
|
// frequency, so the two must not be swapped — a mistake that writes the
|
|
// wrong frequency into every logged QSO.
|
|
rig.split = true
|
|
if s, err = k.ReadState(); err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if !s.Split || s.FreqHz != 14250000 || s.RxFreqHz != 14265000 {
|
|
t.Errorf("split gave tx=%d rx=%d split=%v — want tx 14250000 (A), rx 14265000 (B)",
|
|
s.FreqHz, s.RxFreqHz, s.Split)
|
|
}
|
|
|
|
// Mode: CW below 10 MHz stays CW; the sideband convention only governs SSB.
|
|
rig.split = false
|
|
if err := k.SetMode("CW"); err != nil {
|
|
t.Fatalf("set mode: %v", err)
|
|
}
|
|
if rig.mode != '3' {
|
|
t.Errorf("mode digit = %q, want '3' (CW)", rig.mode)
|
|
}
|
|
|
|
// PTT is a bare command, and the rig must have actually seen it.
|
|
if err := k.SetPTT(true); err != nil {
|
|
t.Fatalf("ptt: %v", err)
|
|
}
|
|
if err := k.SetPTT(false); err != nil {
|
|
t.Fatalf("ptt off: %v", err)
|
|
}
|
|
seen := strings.Join(rig.seen, " ")
|
|
for _, want := range []string{"AI0;", "TX;", "RX;"} {
|
|
if !strings.Contains(seen, want) {
|
|
t.Errorf("the rig never received %s — sent: %s", want, seen)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A port that opens onto silence must be reported as such, not as a connected
|
|
// radio. A powered-off rig logged as "connected" wasted an evening of a user's
|
|
// time on the Yaesu backend.
|
|
func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
|
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
|
k.dialPort = func() (serial.Port, error) {
|
|
return &fakeSerial{toRig: &strings.Builder{}, answer: func(string) string { return "" }}, nil
|
|
}
|
|
err := k.Connect()
|
|
if err == nil {
|
|
t.Fatal("a silent port was reported as a connected rig")
|
|
}
|
|
if !strings.Contains(err.Error(), "sent nothing") {
|
|
t.Errorf("error was %q — a silent port should be reported as silence", err)
|
|
}
|
|
if k.model != "" {
|
|
t.Errorf("a stale model survived a failed connect: %q", k.model)
|
|
}
|
|
}
|
|
|
|
// Split found through FR/FT when the rig never fills IF's split bit.
|
|
//
|
|
// Reported on a Flex through its Kenwood CAT emulation: frequency read
|
|
// perfectly, split never appeared. Rather than guess at which IF column that
|
|
// firmware populates, ask the question that DEFINES split — is the transmit VFO
|
|
// a different VFO from the receive one — which is what FR and FT answer, and
|
|
// the same rule the Yaesu backend settled on after several wrong turns.
|
|
func TestKenwoodSplitFromFRFT(t *testing.T) {
|
|
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', lazyIF: true}
|
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
|
k.dialPort = dialTo(rig)
|
|
if err := k.Connect(); err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer k.Disconnect()
|
|
|
|
// Simplex: FR and FT agree, and nothing may be invented from that.
|
|
s, err := k.ReadState()
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if s.Split {
|
|
t.Errorf("split reported while FR and FT agree: tx=%d rx=%d", s.FreqHz, s.RxFreqHz)
|
|
}
|
|
|
|
// Split on, IF still silent about it: receive on A, transmit on B.
|
|
rig.split = true
|
|
// The FR/FT split re-check is throttled to once a second (it tripled the poll
|
|
// time on a slow K3), so back-to-back reads reuse the last result. Simulate the
|
|
// recheck window having elapsed — in the field the 250 ms poll covers it inside
|
|
// a second.
|
|
k.splitCheckAt = time.Time{}
|
|
if s, err = k.ReadState(); err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if !s.Split {
|
|
t.Fatal("split not detected — FR/FT disagreed and IF's bit was empty, which is the reported case")
|
|
}
|
|
if s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
|
|
t.Errorf("tx=%d rx=%d — want tx 14260000 (B), rx 14250000 (A)", s.FreqHz, s.RxFreqHz)
|
|
}
|
|
}
|
|
|
|
// A rig that rejects FR/FT is asked once, then left alone — and its IF split
|
|
// bit still works. The fallback must not cost a timeout on every poll, nor
|
|
// break the rigs that were already fine.
|
|
func TestKenwoodSplitWhenFRFTRejected(t *testing.T) {
|
|
rig := &ts2000{vfoA: 14250000, vfoB: 14260000, mode: '2', noVFOCmds: true}
|
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
|
k.dialPort = dialTo(rig)
|
|
if err := k.Connect(); err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer k.Disconnect()
|
|
|
|
if _, err := k.ReadState(); err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
asked := 0
|
|
for _, c := range rig.seen {
|
|
if c == "FR;" || c == "FT;" {
|
|
asked++
|
|
}
|
|
}
|
|
if _, err := k.ReadState(); err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
after := 0
|
|
for _, c := range rig.seen {
|
|
if c == "FR;" || c == "FT;" {
|
|
after++
|
|
}
|
|
}
|
|
if after != asked {
|
|
t.Errorf("a rejected command was asked again: %d then %d", asked, after)
|
|
}
|
|
|
|
// IF's own split bit still drives the result on such a rig.
|
|
rig.split = true
|
|
s, err := k.ReadState()
|
|
if err != nil {
|
|
t.Fatalf("read: %v", err)
|
|
}
|
|
if !s.Split || s.FreqHz != 14260000 || s.RxFreqHz != 14250000 {
|
|
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
|
|
}
|
|
}
|
|
|
|
// A rig that talks but never answers what was asked must not be reported as a
|
|
// silent one.
|
|
//
|
|
// The two faults need opposite responses: silence means the radio is off, the
|
|
// wrong port, or a dead cable; noise means the baud rate is wrong or something
|
|
// is echoing the line. "The rig is not answering" sent an operator checking the
|
|
// power switch on a radio whose frames were visibly arriving.
|
|
func TestKenwoodNoisyRigIsReportedAsNoiseNotSilence(t *testing.T) {
|
|
k := NewKenwood("COM-TEST", 9600, "FT8")
|
|
k.dialPort = func() (serial.Port, error) {
|
|
return &fakeSerial{toRig: &strings.Builder{}, answer: func(cmd string) string {
|
|
return "XX9999;" // something, but never the reply to ID; or IF;
|
|
}}, nil
|
|
}
|
|
err := k.Connect()
|
|
if err == nil {
|
|
t.Fatal("a rig answering gibberish was reported as connected")
|
|
}
|
|
if !strings.Contains(err.Error(), "sending data") || !strings.Contains(err.Error(), "XX9999;") {
|
|
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")
|
|
}
|
|
}
|