Files
OpsLog/internal/cat/kenwood_loopback_test.go
T
rouggy eb271e8f20 fix: Kenwood split read from FR/FT when the status frame omits it
Reported from a Flex in Kenwood CAT mode: frequency read perfectly, split never
appeared. Not every rig speaking this dialect fills IF's split bit.

Rather than guess which column that firmware populates, ask the question that
DEFINES split — is the transmit VFO a different VFO from the receive one — which
is exactly what FR and FT answer, and the same rule the Yaesu backend settled on
after several wrong turns. FR is also trusted over IF for which VFO is in use:
they are asked in the same breath, and a rig vague about the split bit may be
just as vague about the VFO field.

Cost is bounded: a rig that rejects FR/FT answers "?;" once and is never asked
again, so this is two short commands per poll only where it works. Both paths
are tested against the emulator — split found through FR/FT with IF silent, and
IF-reported split still working on a rig that refuses FR/FT without re-asking.

Also extends the CAT wire trace to the Kenwood backend, ASCII quoted so an empty
reply is visible as such: "" and ";" look identical unquoted, and telling them
apart is the whole question when a rig half-supports a command. If this fix is
not the whole story on real hardware, the trace is what will say so.
2026-07-30 16:50:06 +02:00

358 lines
11 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(), "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)
}
}
// 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)
}
}