The operator found the menu I said to look for: it offers DAKY, RTS and DTR. KY is the CAT route, so DAKY is the setting that would enable it — which makes my previous "this rig has no CAT keying command" too strong a claim to leave standing, since it was drawn from a refusal at the DEFAULT setting. The error and the settings hint now name DAKY first, and keep the serial line keyer as the fallback. That order matters: the CAT route costs nothing, the fallback costs a second COM port. Marked as untested rather than verified — I have no confirmation that DAKY makes KY work on this model, only that it is the option that should.
289 lines
10 KiB
Go
289 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.
|
|
//
|
|
// Observed on an FTDX10 (2026-07-29): it answered "?;" to both KY; and
|
|
// KY <text>; with PC KEYING left at its default. That rig's CW menu offers
|
|
// DAKY / RTS / DTR, and KY is the CAT route, so DAKY is the setting that should
|
|
// enable it — untested at the time of writing. When the rig refuses, the error
|
|
// says so and names both that setting and the serial line keyer as the fallback,
|
|
// rather than failing silently.
|
|
//
|
|
// 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 == "?;" {
|
|
// Observed on an FTDX10 (2026-07-29). Its PC KEYING menu offers DAKY,
|
|
// RTS and DTR: KY is the CAT route, so DAKY is the setting that would
|
|
// enable it — worth naming, since the alternative costs the operator a
|
|
// second COM port. If DAKY does not help, the line keyer does.
|
|
return fmt.Errorf("the radio refused the CW keying command (it answered \"?;\" to KY). " +
|
|
"Set PC KEYING to DAKY in the rig's CW menu; if that does not help, use the " +
|
|
"\"Serial port (DTR=CW / RTS=PTT)\" keyer on the rig's other COM port (the standard " +
|
|
"one) with PC KEYING set to DTR, while CAT keeps the enhanced port")
|
|
}
|
|
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()), " ")
|
|
}
|