test: drive the Kenwood backend against a rig that answers
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 test's responder 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 instead of against my reading of a manual. Connect adds a dialPort seam for this; nothing else changed in the backend. Covered: model identification, simplex on A, everything following the VFO in use when on B, tuning writing to that VFO rather than blindly to FA, split with TX and RX the right way round (ADIF FREQ is the TRANSMIT frequency — swapping them writes the wrong frequency into every logged QSO), the mode digit, and a silent port reported as not answering rather than as a connected radio. The last three are each a fault that actually reached a user through the Yaesu backend. What this does NOT prove: that a real TS-590 answers on the same timings, or fills every IF field the way its documentation says. That still needs a radio.
This commit is contained in:
+15
-1
@@ -48,6 +48,12 @@ type Kenwood struct {
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
|
||||
// dialPort, when set, replaces serial.Open. It exists so the backend can be
|
||||
// driven against internal/catemu — which already SPEAKS this dialect to
|
||||
// satisfy an ACOM amplifier — without a radio, a COM port or a null-modem
|
||||
// pair. Written for a Kenwood backend nobody here owns a rig to test.
|
||||
dialPort func() (serial.Port, error)
|
||||
|
||||
model string
|
||||
curFreq int64
|
||||
curRXFreq int64
|
||||
@@ -82,7 +88,7 @@ func (k *Kenwood) Connect() error {
|
||||
_ = k.port.Close()
|
||||
k.port = nil
|
||||
}
|
||||
p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
||||
p, err := k.openPort()
|
||||
if err != nil {
|
||||
return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err)
|
||||
}
|
||||
@@ -411,3 +417,11 @@ var kenwoodModels = map[string]string{
|
||||
"024": "TS-990S",
|
||||
"025": "TS-890S",
|
||||
}
|
||||
|
||||
// openPort opens the serial link, or whatever dialPort provides in a test.
|
||||
func (k *Kenwood) openPort() (serial.Port, error) {
|
||||
if k.dialPort != nil {
|
||||
return k.dialPort()
|
||||
}
|
||||
return serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
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
|
||||
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 {
|
||||
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 ""
|
||||
}
|
||||
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(), "not answering") {
|
||||
t.Errorf("error was %q — it should say the rig is not answering", err)
|
||||
}
|
||||
if k.model != "" {
|
||||
t.Errorf("a stale model survived a failed connect: %q", k.model)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user