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.
277 lines
9.2 KiB
Go
277 lines
9.2 KiB
Go
package cat
|
|
|
|
// CW keying through the Yaesu's own keyer — the KY command.
|
|
//
|
|
// This is the fifth CW engine, alongside WinKeyer, the DTR/RTS line keyer, the
|
|
// Icom CI-V keyer and FlexRadio's CWX. The point is the same in each: no extra
|
|
// hardware, no second cable. The radio holds the text and keys it with its own
|
|
// timing, which is why the character spacing is perfect where a PC keying a line
|
|
// through USB latency is not.
|
|
//
|
|
// KY; → KY0; buffer has room / KY1; buffer full
|
|
// KY <text>; queue up to 24 characters
|
|
//
|
|
// The leading SPACE after KY is part of the command, not padding.
|
|
//
|
|
// Verified on: nothing yet — the send path follows the FTDX10/FTDX101 CAT
|
|
// reference and the way Hamlib drives these rigs. STOP is the uncertain half:
|
|
// Yaesu documents no way to clear the buffer, so see StopCW.
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// yaesuCWChunk is the most characters one KY command accepts. Longer text is
|
|
// split and fed as the rig drains its buffer.
|
|
const yaesuCWChunk = 24
|
|
|
|
// cwStatusLogged makes the KY status reply appear in the log once per run: it
|
|
// is a line we have no verified sample of, so the first one is worth keeping.
|
|
var cwStatusLogged bool
|
|
|
|
// yaesuCWAllowed is what the rig's keyer can actually send. Anything else is
|
|
// dropped rather than passed through: an unsupported byte can abort the whole
|
|
// buffer, losing the rest of the message with no error anywhere.
|
|
const yaesuCWAllowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+:;()"
|
|
|
|
// SendCW queues a message on the rig's keyer.
|
|
//
|
|
// Text is filtered, upper-cased and fed in 24-character pieces, waiting for room
|
|
// between pieces. Without that wait a long macro would silently lose its tail:
|
|
// the rig drops what does not fit rather than reporting an error.
|
|
func (y *Yaesu) SendCW(text string) error {
|
|
msg := filterYaesuCW(text)
|
|
if msg == "" {
|
|
return nil
|
|
}
|
|
for len(msg) > 0 {
|
|
n := yaesuCWChunk
|
|
if len(msg) < n {
|
|
n = len(msg)
|
|
}
|
|
chunk := msg[:n]
|
|
msg = msg[n:]
|
|
if err := y.waitCWBuffer(4 * time.Second); err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
// When the rig will not tell us how full its buffer is (an FTDX10 answers
|
|
// "?;" to KY;), pace the next piece by how long this one takes to key.
|
|
// Sending everything at once overruns the 24-character buffer and the rig
|
|
// silently drops the rest.
|
|
if len(msg) > 0 && y.cwStatusUnsupported() {
|
|
time.Sleep(yaesuCWDuration(chunk, y.keyerWPM()))
|
|
}
|
|
}
|
|
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()
|
|
defer y.mu.Unlock()
|
|
return y.unsupported["KY;"]
|
|
}
|
|
|
|
// keyerWPM is the speed the rig is keying at, for pacing. Falls back to a
|
|
// middling 20 wpm when the rig does not report it: too slow only wastes a
|
|
// moment, too fast overruns the buffer.
|
|
func (y *Yaesu) keyerWPM() int {
|
|
y.mu.Lock()
|
|
defer y.mu.Unlock()
|
|
if y.panel.KeySpeed >= 4 {
|
|
return y.panel.KeySpeed
|
|
}
|
|
return 20
|
|
}
|
|
|
|
// yaesuCWDuration estimates how long a piece of text takes to key.
|
|
//
|
|
// PARIS timing: one word is 50 dit-lengths and a word is 5 characters, so a
|
|
// character averages 10 dits and a dit is 1.2/wpm seconds.
|
|
func yaesuCWDuration(text string, wpm int) time.Duration {
|
|
if wpm < 4 {
|
|
wpm = 20
|
|
}
|
|
ditMs := 1200.0 / float64(wpm)
|
|
return time.Duration(float64(len(text))*10*ditMs) * time.Millisecond
|
|
}
|
|
|
|
// StopCW aborts the message being sent.
|
|
//
|
|
// Yaesu documents no buffer-clear, so this drops the transmitter instead: TX0
|
|
// takes the rig out of transmit, which is what an operator pressing Escape
|
|
// actually wants. Anything still queued is not keyed on the air.
|
|
//
|
|
// It deliberately does NOT send "KY0;" — a plausible-looking clear that the rig
|
|
// would read as the CHARACTER zero and dutifully send. A wrong guess here
|
|
// transmits, which is worse than an imperfect abort.
|
|
func (y *Yaesu) StopCW() error {
|
|
y.mu.Lock()
|
|
defer y.mu.Unlock()
|
|
if y.port == nil {
|
|
return fmt.Errorf("yaesu: not connected")
|
|
}
|
|
return y.write("TX0;")
|
|
}
|
|
|
|
// waitCWBuffer blocks until the keyer reports room, or the deadline passes.
|
|
// A rig that never answers the status query is not a reason to refuse to send —
|
|
// we go ahead once, and the worst case is the truncation we were avoiding.
|
|
func (y *Yaesu) waitCWBuffer(within time.Duration) error {
|
|
deadline := time.Now().Add(within)
|
|
for {
|
|
y.mu.Lock()
|
|
if y.port == nil {
|
|
y.mu.Unlock()
|
|
return fmt.Errorf("yaesu: not connected")
|
|
}
|
|
if y.unsupported["KY;"] {
|
|
y.mu.Unlock()
|
|
return nil // this rig does not report buffer state — pacing covers it
|
|
}
|
|
r, err := y.ask("KY;")
|
|
if err != nil {
|
|
if errors.Is(err, errYaesuUnsupported) {
|
|
if y.unsupported == nil {
|
|
y.unsupported = map[string]bool{}
|
|
}
|
|
y.unsupported["KY;"] = true
|
|
debugLog.Printf("yaesu cw: this rig does not answer KY; — pacing the send by keying time instead")
|
|
} else {
|
|
debugLog.Printf("yaesu cw: KY status query failed (%v) — sending anyway", err)
|
|
}
|
|
y.mu.Unlock()
|
|
return nil
|
|
}
|
|
y.mu.Unlock()
|
|
// Only an explicit "full" holds us back.
|
|
//
|
|
// The first version required the reply to be exactly "KY0;" at byte 2, and
|
|
// on a real FTDX10 that refused to send at all: anything the rig phrases
|
|
// differently — a space before the digit, a longer status — read as "still
|
|
// full" and the send died after four seconds having keyed nothing. The rig
|
|
// is the one that knows; when its answer is not a clear "1", the right move
|
|
// is to go ahead rather than to sit and time out.
|
|
// Log the shape once per session: this is a reply we have no verified
|
|
// sample of, and the log is what turns the next surprise into a fact.
|
|
if !cwStatusLogged {
|
|
cwStatusLogged = true
|
|
debugLog.Printf("yaesu cw: KY status reply is %q (full=%v)", r, yaesuCWBufferFull(r))
|
|
}
|
|
if !yaesuCWBufferFull(r) {
|
|
return nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("yaesu: the keyer buffer stayed full for %s (last reply %q)", within, r)
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// yaesuCWBufferFull reads the KY status reply.
|
|
//
|
|
// Deliberately asymmetric: only a clear "1" means full. Anything else — an
|
|
// unexpected shape, a reply from another command that arrived first, an empty
|
|
// string — is treated as "go ahead". Refusing to send because a status line was
|
|
// phrased unexpectedly is the worse failure: the operator gets silence with no
|
|
// explanation, where at worst sending early truncates a long message.
|
|
func yaesuCWBufferFull(reply string) bool {
|
|
r := strings.TrimSpace(reply)
|
|
if !strings.HasPrefix(strings.ToUpper(r), "KY") {
|
|
return false
|
|
}
|
|
for _, c := range r[2:] {
|
|
switch c {
|
|
case '0':
|
|
return false
|
|
case '1':
|
|
return true
|
|
case ' ', ';':
|
|
continue
|
|
default:
|
|
return false // not a status digit — don't block on it
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// filterYaesuCW upper-cases and strips what the keyer cannot send.
|
|
func filterYaesuCW(text string) string {
|
|
var b strings.Builder
|
|
for _, r := range strings.ToUpper(text) {
|
|
// Any whitespace becomes a word gap FIRST. Dropping tabs and newlines as
|
|
// "not in the allowed set" glued the words either side together: a macro
|
|
// written on two lines went out as CQCQ.
|
|
if r == '\t' || r == '\n' || r == '\r' {
|
|
b.WriteByte(' ')
|
|
continue
|
|
}
|
|
if strings.ContainsRune(yaesuCWAllowed, r) {
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
// Runs of spaces become one: the rig sends each as a word gap, so three in a
|
|
// row is three gaps and the message crawls.
|
|
return strings.Join(strings.Fields(b.String()), " ")
|
|
}
|