Files
OpsLog/internal/cat/kenwood_loopback_test.go
T
rouggy 3dd428d748 fix(cat): stop deasserting DTR/RTS on Kenwood and Yaesu
d4bfd30 fixed a Xiegu G90 behind a DE-19 that keyed on connect, and I
applied the same line-lowering to Kenwood and Yaesu with no evidence that
either needed it. It silenced them: many USB-serial interfaces will not
transmit with RTS low — hardware flow control, or an output stage the
line enables. A TS-990 on COM3 opened cleanly and answered nothing, which
reached us as "CAT stopped working after the update".

Xiegu keeps the behaviour: that is where the fault was reported, and that
backend now has an explicit setting for which line keys the rig. The CI-V
backend keeps its own, which predates all of this.

Kenwood also distinguishes a silent port from one sending data that never
answers, and quotes what arrived — "the rig is not answering" sent an
operator checking the power switch on a radio whose frames were visibly
on the wire.
2026-08-01 12:09:11 +02:00

381 lines
12 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
}
}
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
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)
}
}