diff --git a/internal/cat/yaesu.go b/internal/cat/yaesu.go index 062e07f..8e82927 100644 --- a/internal/cat/yaesu.go +++ b/internal/cat/yaesu.go @@ -184,6 +184,15 @@ func (y *Yaesu) ReadState() (RigState, error) { s := RigState{Backend: y.Name(), Connected: true, Rig: y.model} 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 { return RigState{}, err // the rig stopped answering — let the Manager reconnect } diff --git a/internal/cat/yaesu_cw.go b/internal/cat/yaesu_cw.go index 67175ea..23e838b 100644 --- a/internal/cat/yaesu_cw.go +++ b/internal/cat/yaesu_cw.go @@ -59,6 +59,16 @@ func (y *Yaesu) SendCW(text string) error { } y.mu.Lock() 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() if err != nil { return err @@ -74,6 +84,47 @@ func (y *Yaesu) SendCW(text string) error { 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. func (y *Yaesu) cwStatusUnsupported() bool { y.mu.Lock()