feat: CW through the Yaesu keyer — fifth CW engine

The radio has a keyer and a command to feed it (KY), so an FTDX10 needs no
WinKeyer and no second cable, exactly as the Icom CI-V and Flex CWX engines
already do. The rig keys with its own timing, which is why the spacing is right
where a PC keying a line through USB latency drifts.

Text is filtered to what the keyer can actually send: an unsupported byte does
not produce an error on a Yaesu, it can abort the whole buffer, so the rest of a
macro would vanish silently. It is then fed in 24-character pieces, waiting for
room between them — the rig DROPS what does not fit, again with no error, so a
contest CQ would lose its tail.

STOP is the honest gap. Yaesu documents no buffer-clear, so it drops the
transmitter (TX0) instead: nothing queued reaches the air, which is what Escape
means to an operator. It deliberately does NOT send "KY0;" — a plausible-looking
clear that the rig would read as the CHARACTER zero and transmit.

A test caught a real one on the way: tabs and newlines were dropped as
"unsupported", gluing the words either side together, so a macro written on two
lines went out as CQCQ. Whitespace now becomes a word gap before filtering.

Settings warn when the Yaesu keyer is selected without the Yaesu CAT backend —
otherwise it simply never keys, with nothing on screen saying why.

Send path follows the CAT reference and how Hamlib drives these rigs; NOT yet
verified on the air.
This commit is contained in:
2026-07-29 12:42:40 +02:00
parent 520d17810c
commit 08f3e54afb
11 changed files with 252 additions and 11 deletions
+128
View File
@@ -0,0 +1,128 @@
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 (
"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
// 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 + ";")
y.mu.Unlock()
if err != nil {
return err
}
}
return nil
}
// 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")
}
r, err := y.ask("KY;")
y.mu.Unlock()
if err != nil {
debugLog.Printf("yaesu cw: KY status query failed (%v) — sending anyway", err)
return nil
}
if len(r) >= 3 && r[2] == '0' {
return nil // room in the buffer
}
if time.Now().After(deadline) {
return fmt.Errorf("yaesu: the keyer buffer stayed full for %s", within)
}
time.Sleep(50 * time.Millisecond)
}
}
// 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()), " ")
}
+63
View File
@@ -0,0 +1,63 @@
package cat
import (
"strings"
"testing"
)
// What reaches the rig's keyer.
//
// An unsupported byte does not produce an error on a Yaesu — it can abort the
// whole buffer, so the rest of the message is lost silently. Filtering is
// therefore part of correctness, not tidiness.
func TestFilterYaesuCW(t *testing.T) {
cases := []struct{ in, want string }{
{"cq cq de f4bpo", "CQ CQ DE F4BPO"},
{"F4BPO/P", "F4BPO/P"},
{"599 tu 73", "599 TU 73"},
// Accents and symbols the keyer has no code for.
{"héllo", "HLLO"},
{"a*b#c", "ABC"},
// Runs of whitespace collapse: the rig sends each space as a word gap, so
// three in a row is three gaps and the message crawls.
{"cq cq", "CQ CQ"},
{" padded ", "PADDED"},
{"\tcq\ncq\t", "CQ CQ"},
{"", ""},
{" ", ""},
}
for _, c := range cases {
if got := filterYaesuCW(c.in); got != c.want {
t.Errorf("filterYaesuCW(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// Long macros have to be split: one KY command carries 24 characters and the rig
// DROPS what does not fit rather than reporting an error, so a contest CQ would
// lose its tail with no sign anywhere.
func TestYaesuCWChunking(t *testing.T) {
msg := filterYaesuCW("cq cq cq de f4bpo f4bpo f4bpo pse k")
if len(msg) <= yaesuCWChunk {
t.Fatalf("test message is %d chars — too short to exercise chunking", len(msg))
}
var chunks []string
for rest := msg; len(rest) > 0; {
n := yaesuCWChunk
if len(rest) < n {
n = len(rest)
}
chunks = append(chunks, rest[:n])
rest = rest[n:]
}
for i, c := range chunks {
if len(c) > yaesuCWChunk {
t.Errorf("chunk %d is %d chars, over the %d limit", i, len(c), yaesuCWChunk)
}
}
// Nothing added, nothing lost: the message the operator typed is what the
// keyer receives.
if joined := strings.Join(chunks, ""); joined != msg {
t.Errorf("chunks rejoin to %q, want %q", joined, msg)
}
}
+3
View File
@@ -82,6 +82,9 @@ type YaesuController interface {
SetYaesuKeySpeed(int) error
SetYaesuBreakIn(bool) error
YaesuZeroIn() error
// CW keying through the rig's own keyer (KY) — the fifth CW engine.
SendCW(string) error
StopCW() error
SetYaesuSplit(bool) error
SetYaesuSplitOffset(int64) error
SetYaesuBand(string) error