Confirmed on the radio: it answers "?;" to KY, so this is not a setting to find. My previous message sent the operator looking for a MENU → CW → PC KEYING entry I named without checking it exists on that model, and they could not find it. The message now names the path that does work: the "Serial port (DTR=CW / RTS=PTT)" keyer on the rig's OTHER COM port — the standard one — while CAT keeps the enhanced one. Same for the settings hint and the changelog. The KY engine stays: it is documented for the FTDX101 / FT-991A / FT-710 family. It now fails loudly and usefully on the models that lack it, which is the difference between a dead feature and a wrong one.
286 lines
9.8 KiB
Go
286 lines
9.8 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: an FTDX10 REFUSES this — it answers "?;" to both KY; and KY <text>;,
|
|
// so that model has no CAT keying command at all and needs the serial DTR keyer
|
|
// on its second COM port instead. The code stays because KY is documented for
|
|
// the FTDX101 / FT-991A / FT-710 family; it now says so plainly when refused
|
|
// rather than failing silently. STOP is the other uncertain half: Yaesu
|
|
// documents no buffer-clear, 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 == "?;" {
|
|
// Confirmed on an FTDX10 (2026-07-29): it answers "?;" to KY, so this
|
|
// rig simply has no CAT keying command — it is not a setting to find.
|
|
// The message therefore names the way that DOES work rather than
|
|
// sending the operator hunting through menus.
|
|
return fmt.Errorf("this rig does not accept CW over CAT (it answers \"?;\" to KY). " +
|
|
"Use the \"Serial port (DTR=CW / RTS=PTT)\" keyer on the rig's OTHER COM port — " +
|
|
"the standard one, while CAT keeps the enhanced one")
|
|
}
|
|
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()), " ")
|
|
}
|