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.
This commit is contained in:
2026-07-30 16:50:06 +02:00
parent e2d2485703
commit eb271e8f20
6 changed files with 206 additions and 25 deletions
+57
View File
@@ -160,6 +160,34 @@ func (k *Kenwood) ReadState() (RigState, error) {
// IF reports the frequency of the VFO in USE (what the operator hears).
rx := f.FreqHz
tx := rx
// IF's split bit is not filled in by every rig that speaks this dialect —
// reported on a Flex through its Kenwood CAT emulation, where the frequency
// reads perfectly and split never appears. So ask the question directly as
// well: split IS "the transmit VFO differs from the receive VFO", which is
// what FR/FT answer, and it is the same rule the Yaesu backend settled on.
//
// A rig that rejects FR/FT answers "?;" once and is never asked again, so
// this costs two short commands per poll only where it actually works.
split := f.Split
if !split {
rxv, rxOK := k.askVFO("FR;")
txv, txOK := k.askVFO("FT;")
if rxOK && txOK && rxv != txv {
split = true
// Trust FR over IF for which VFO is in use: they were asked in the
// same breath, and a rig that leaves the split bit empty may be just
// as vague about the VFO field.
// f.VFO too, not just the reported state: the block below picks the
// TRANSMIT VFO as "the other one" from f.VFO, and leaving the two
// disagreeing would read the transmit frequency off the wrong dial.
if rxv == "A" || rxv == "B" {
k.curVFO, s.Vfo, f.VFO = rxv, rxv, rxv
}
}
}
f.Split = split
if f.Split {
// The transmit VFO is the other one. Read it rather than assume, and fall
// back to simplex if it cannot be read: a wrong TX frequency is written
@@ -235,6 +263,7 @@ func (k *Kenwood) write(cmd string) error {
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
traceText("kenwood", "TX", cmd)
_, err := k.port.Write([]byte(cmd))
return err
}
@@ -270,6 +299,7 @@ func (k *Kenwood) ask(cmd string) (string, error) {
}
frame := string(buf[:i+1])
buf = buf[i+1:]
traceText("kenwood", "RX", frame)
if frame == "?;" {
// The rig rejected the command. Remember it so the poll loop stops
// paying a 600 ms timeout for it on every cycle.
@@ -425,3 +455,30 @@ func (k *Kenwood) openPort() (serial.Port, error) {
}
return serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
}
// askVFO asks FR; or FT; and returns "A" or "B".
//
// The reply is FR0; / FR1; — the digit right after the two-letter command.
// Anything else (a rig that answers with more fields, or not at all) returns
// false, and the caller keeps whatever IF said rather than inventing a split.
func (k *Kenwood) askVFO(cmd string) (string, bool) {
r, err := k.ask(cmd)
if err != nil {
return "", false
}
want := cmdPrefix(cmd)
body := strings.TrimSuffix(strings.TrimPrefix(r, want), ";")
if body == "" {
return "", false
}
switch body[0] {
case '0':
return "A", true
case '1':
return "B", true
}
// 2 is "sub receiver" on a TS-2000 — real, but not a VFO we track. Saying
// nothing is better than mapping it onto A or B and reporting a split that
// does not exist.
return "", false
}
+106 -2
View File
@@ -108,7 +108,13 @@ type ts2000 struct {
mode byte
onB bool
split bool
seen []string
// 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 {
@@ -127,7 +133,7 @@ func (r *ts2000) answer(cmd string) string {
return fmt.Sprintf("FB%011d;", r.vfoB)
case cmd == "IF;":
split := byte('0')
if r.split {
if r.split && !r.lazyIF {
split = '1'
}
// The catemu layout: IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) |
@@ -143,6 +149,21 @@ func (r *ts2000) answer(cmd string) string {
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
}
@@ -251,3 +272,86 @@ func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
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)
}
}
+34 -18
View File
@@ -75,37 +75,53 @@ func DebugLogPath() string {
return filepath.Join(base, "OpsLog", "cat.log")
}
// ── CI-V byte trace ────────────────────────────────────────────────────────
// ── CAT wire trace ─────────────────────────────────────────
//
// Opt-in, off by default. Turning it on logs every CI-V frame sent and received
// as hex, exactly like the WinKeyer protocol trace.
// Opt-in, off by default. Turning it on logs every frame sent and received
// CI-V as hex, the ASCII backends (Kenwood, Yaesu) as the text they exchange.
//
// That trace exists because a fault nobody could reason about — "the keyer sends
// one element then stalls" — was settled in one line the moment the actual bytes
// were visible. The CI-V link has now produced the same class of report: a MOX
// button that sets split instead of transmitting, on a rig whose PTT command is
// demonstrably correct in the source. Guessing at that from the command table
// has already cost this project a wrong fix; the bytes will say what the rig was
// really asked.
var civTrace atomic.Bool
// It exists because a fault nobody could reason about — "the keyer sends one
// element then stalls" — was settled in one line the moment the actual bytes
// were visible, and the CAT links have since produced the same class of report:
// a MOX button that sets split instead of transmitting, and a split the Kenwood
// backend does not recognise on a rig whose frequency it reads perfectly.
//
// Both are questions about what the RIG actually said. Guessing at that from a
// protocol document has already cost this project a wrong fix that broke the
// case which already worked.
var catTrace atomic.Bool
// SetCIVTrace turns the CI-V byte trace on or off.
// SetCIVTrace turns the CAT wire trace on or off. Named for the CI-V link it
// was written for; it now covers the text backends too.
func SetCIVTrace(on bool) {
civTrace.Store(on)
catTrace.Store(on)
if on {
debugLog.Printf("civ trace ON — every CI-V frame to and from the rig is logged")
debugLog.Printf("wire trace ON — every CAT frame to and from the rig is logged")
} else {
debugLog.Printf("civ trace OFF")
debugLog.Printf("wire trace OFF")
}
}
// CIVTraceEnabled reports whether the trace is running (for the settings UI).
func CIVTraceEnabled() bool { return civTrace.Load() }
func CIVTraceEnabled() bool { return catTrace.Load() }
// traceCIV logs one frame. dir is "TX" (to the rig) or "RX" (from it).
// traceCIV logs one CI-V frame. dir is "TX" (to the rig) or "RX" (from it).
func traceCIV(dir string, b []byte) {
if !civTrace.Load() || len(b) == 0 {
if !catTrace.Load() || len(b) == 0 {
return
}
debugLog.Printf("civ %s % X", dir, b)
}
// traceText logs one ASCII exchange, for the Kenwood and Yaesu backends.
//
// The text is QUOTED. A truncated or empty reply must be visible as such
// rather than vanish into the line: "" and ";" look identical unquoted, and
// telling them apart is the whole question when a rig answers a command it
// does not really support.
func traceText(backend, dir, s string) {
if !catTrace.Load() || s == "" {
return
}
debugLog.Printf("%s %s %q", backend, dir, s)
}