fix(cat): keep the Kenwood read buffer across commands; log the CAT error

A TS-480 connected, answered ID, then never reported a state:

  discarding " 000000000010000000;" while waiting for IF
  connected on COM3 @ 115200 baud
  cat:state → connected=false freq=0

A headless frame tail can only come from bytes that were thrown away.
ask() buffered into a local slice, so everything left after the matched
frame — including the head of a frame still arriving — went with it, and
the link could sit one answer behind its questions. The buffer now lives
on the backend, and Connect drains whatever the rig said before AI0 took
effect rather than inheriting it.

Also: the cat:state diagnostic prints RigState.Error. It read
"connected=false freq=0" and said nothing about why, so every report of a
CAT failure arrived without the one fact that explains it — the reason
only ever reached a tooltip.

New work moves to 0.22.8; 0.22.7 is released.
This commit is contained in:
2026-08-01 12:04:32 +02:00
parent a83acb0f9a
commit c62d992ad6
3 changed files with 82 additions and 20 deletions
+54 -14
View File
@@ -29,6 +29,7 @@ package cat
// from the radio, and the rig file decides what a "Freq" property means.
import (
"bytes"
"errors"
"fmt"
"net"
@@ -71,6 +72,17 @@ type Kenwood struct {
curVFO string // "A" or "B"
// Commands this rig answered "?;" to — asked once, then never again.
unsupported map[string]bool
// rx holds bytes read but not yet consumed, ACROSS calls to ask.
//
// It has to survive: a rig answers faster than we ask, so one Read often
// returns a whole reply plus the start of the next frame. When this buffer
// was local to ask, everything after the matched frame was dropped — half a
// frame included — and the link desynchronised permanently: every ask then
// found the PREVIOUS command's answer and timed out waiting for its own.
// That is the "discarding \" 000000000010000000;\" while waiting for IF"
// a TS-480 reported, followed by connected=false for ever.
rx []byte
}
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
@@ -122,6 +134,13 @@ func (k *Kenwood) Connect() error {
// request/response pairs and make a reply impossible to attribute. We poll.
_ = k.write("AI0;")
// Start from silence. A reconnect inherits whatever the rig said last —
// unsolicited frames sent before AI0 landed, the tail of an answer nobody
// read — and one stale frame is enough to leave every ask one reply behind
// its question for the rest of the session.
k.rx = nil
k.drain(250 * time.Millisecond)
answered := false
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
answered = true
@@ -300,6 +319,23 @@ func (k *Kenwood) write(cmd string) error {
return err
}
// drain reads and throws away whatever the rig has already sent, until it stays
// quiet for one read timeout or the budget runs out.
func (k *Kenwood) drain(budget time.Duration) {
if k.port == nil {
return
}
tmp := make([]byte, 256)
deadline := time.Now().Add(budget)
for time.Now().Before(deadline) {
n, err := k.port.Read(tmp)
if err != nil || n == 0 {
return // an error here is not interesting: we are throwing this away
}
traceText("kenwood", "RX-drop", string(tmp[:n]))
}
}
// ask sends a query and returns the reply belonging to THAT command. Anything
// else on the wire is discarded: a stray frame parsed as a frequency reads as
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping
@@ -312,25 +348,18 @@ func (k *Kenwood) ask(cmd string) (string, error) {
if err := k.write(cmd); err != nil {
return "", err
}
buf := make([]byte, 0, 64)
tmp := make([]byte, 64)
deadline := time.Now().Add(600 * time.Millisecond)
for time.Now().Before(deadline) {
n, err := k.port.Read(tmp)
if err != nil {
return "", err
}
if n == 0 {
continue // read timeout — the rig may still be composing its answer
}
buf = append(buf, tmp[:n]...)
for {
// Consume whatever is already buffered BEFORE reading more: the answer
// may have arrived attached to the previous one.
for {
i := strings.IndexByte(string(buf), ';')
i := bytes.IndexByte(k.rx, ';')
if i < 0 {
break
}
frame := string(buf[:i+1])
buf = buf[i+1:]
frame := string(k.rx[:i+1])
k.rx = k.rx[i+1:]
traceText("kenwood", "RX", frame)
if frame == "?;" {
// The rig rejected the command. Remember it so the poll loop stops
@@ -344,8 +373,19 @@ func (k *Kenwood) ask(cmd string) (string, error) {
}
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
}
if !time.Now().Before(deadline) {
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
}
n, err := k.port.Read(tmp)
if err != nil {
return "", err
}
if n > 0 {
k.rx = append(k.rx, tmp[:n]...)
}
// n == 0 is a read timeout, not silence for good: the rig may still be
// composing its answer.
}
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
}
// ── Frame parsing ──────────────────────────────────────────────────────────