Tested on the radio: DAKY does not help, KY is refused whatever PC KEYING is set to, and the "Serial port (DTR=CW / RTS=PTT)" engine on the second COM port keys correctly. So my earlier "try DAKY first" was wrong, and the order is now the other way round: name what works, mention the model that refuses. The KY engine stays. It is documented for the FTDX101 / FT-991A / FT-710 family and costs nothing to keep — a rig that refuses it now says so in one clear sentence instead of transmitting nothing for no stated reason.
291 lines
10 KiB
Go
291 lines
10 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.
|
|
//
|
|
// CONFIRMED on an FTDX10 (2026-07-29): it refuses this outright, answering "?;"
|
|
// to both KY; and KY <text>; — including with PC KEYING on DAKY, the obvious
|
|
// candidate, which does NOT help. That model keys from a PC only through the DTR
|
|
// line of its second (standard) COM port, which is OpsLog's "serial" engine and
|
|
// is confirmed working.
|
|
//
|
|
// The code stays because KY is documented for the FTDX101 / FT-991A / FT-710
|
|
// family. On a rig that refuses it, the error now names the route that works.
|
|
//
|
|
// STOP is the other uncertain half: Yaesu documents no buffer-clear, 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 == "?;" {
|
|
// An FTDX10 refuses KY whatever PC KEYING is set to — DAKY included,
|
|
// tested on the radio 2026-07-29. The message therefore points
|
|
// straight at what works, rather than at a setting that will not
|
|
// help.
|
|
return fmt.Errorf("this radio does not accept CW over CAT (it answered \"?;\" to KY). " +
|
|
"Switch the keyer engine to \"Serial port (DTR=CW / RTS=PTT)\" on the rig's OTHER " +
|
|
"COM port — the standard one, CAT keeping the enhanced one — with PC KEYING set " +
|
|
"to DTR in the rig's CW menu")
|
|
}
|
|
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()), " ")
|
|
}
|