fix: read the rig's answer to KY — it was killing the CAT link, silently

The log timed it exactly: the CW send at 13:16:17.591, the CAT dropping at
13:16:17.633, forty milliseconds later.

An accepted KY says nothing, but a REJECTED one answers "?;" — and nobody was
reading it. The frame sat in the buffer until the poll loop's next query picked
it up, failed, and the Manager tore the link down. That is the disconnect on
every macro click, and it also explains the silence: the backend was being
rebuilt underneath the send.

Two changes. The KY write is now followed by a short read: silence means
accepted, "?;" means refused — and the operator is told so, naming MENU → CW →
PC KEYING, instead of getting nothing with no reason. And FA; retries once when
it meets a stray rejection, because a "?;" arriving there is almost never about
FA: the rig answers frequency queries perfectly well, it is the previous
command's refusal being attributed to this one.

This does not yet prove the FTDX10 accepts KY at all. It makes the next run say
so plainly either way, which is the point.
This commit is contained in:
2026-07-29 13:18:52 +02:00
parent d114a73729
commit 40df4fe22f
2 changed files with 60 additions and 0 deletions
+9
View File
@@ -184,6 +184,15 @@ func (y *Yaesu) ReadState() (RigState, error) {
s := RigState{Backend: y.Name(), Connected: true, Rig: y.model} s := RigState{Backend: y.Name(), Connected: true, Rig: y.model}
faRaw, err := y.ask("FA;") faRaw, err := y.ask("FA;")
if errors.Is(err, errYaesuUnsupported) {
// A "?;" here is almost never about FA — the rig answers frequency queries
// perfectly well. It is a rejection left over from the PREVIOUS command
// that our read then attributed to this one. Retrying once costs a few
// milliseconds; treating it as "lost the rig" tore the CAT link down and
// reconnected it, which is what the operator saw on every CW macro.
debugLog.Printf("yaesu: FA; got a stray rejection — retrying once")
faRaw, err = y.ask("FA;")
}
if err != nil { if err != nil {
return RigState{}, err // the rig stopped answering — let the Manager reconnect return RigState{}, err // the rig stopped answering — let the Manager reconnect
} }
+51
View File
@@ -59,6 +59,16 @@ func (y *Yaesu) SendCW(text string) error {
} }
y.mu.Lock() y.mu.Lock()
err := y.write("KY " + chunk + ";") err := y.write("KY " + chunk + ";")
if err == nil {
// KY produces no reply when it is accepted — but a rig that REJECTS it
// answers "?;", and that frame then sat in the buffer until the poll
// loop's next query picked it up, failed, and the Manager tore the link
// down. That is the CAT dropping for a few seconds on every macro click.
//
// Reading here does two things: it keeps the stray frame off the poll
// loop, and it turns a silent non-transmission into a stated reason.
err = y.drainCWReply()
}
y.mu.Unlock() y.mu.Unlock()
if err != nil { if err != nil {
return err return err
@@ -74,6 +84,47 @@ func (y *Yaesu) SendCW(text string) error {
return nil return nil
} }
// drainCWReply reads for a moment after a KY write.
//
// An accepted KY says nothing at all, so silence here is success. A rejection
// answers "?;" — and left unread, that frame was picked up by the poll loop's
// next query, which failed to parse it and took the whole CAT link down with it.
// Reading it here keeps the link up AND turns "nothing was transmitted, no idea
// why" into a stated reason.
//
// The caller holds the mutex.
func (y *Yaesu) drainCWReply() error {
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
deadline := time.Now().Add(200 * time.Millisecond)
buf := make([]byte, 0, 32)
tmp := make([]byte, 32)
for time.Now().Before(deadline) {
n, err := y.port.Read(tmp)
if err != nil {
return nil // a read problem here is the poll loop's business, not ours
}
if n == 0 {
continue
}
buf = append(buf, tmp[:n]...)
for {
i := strings.IndexByte(string(buf), ';')
if i < 0 {
break
}
frame := strings.TrimSpace(string(buf[:i+1]))
buf = buf[i+1:]
if frame == "?;" {
return fmt.Errorf("the radio rejected the CW keying command (KY). On an FTDX10, check MENU → CW → PC KEYING")
}
debugLog.Printf("yaesu cw: rig answered %q to KY — ignoring", frame)
}
}
return nil // silence = accepted
}
// cwStatusUnsupported reports whether this rig refused the KY status query. // cwStatusUnsupported reports whether this rig refused the KY status query.
func (y *Yaesu) cwStatusUnsupported() bool { func (y *Yaesu) cwStatusUnsupported() bool {
y.mu.Lock() y.mu.Lock()