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