The log settled it: the rig answers "?;" — its "unknown command" — to KY; and to MG;. So the keyer status query cannot work on this model, and every send spent four seconds waiting for an answer that was never coming. "?;" is now recognised as what it is. A command refused once is never asked again: models implement different subsets, and re-asking costs a 600 ms timeout on every slow beat for a control that will never answer — which is also why the panel felt sluggish. Without a buffer status there is still a real constraint: one KY command carries 24 characters and the rig DROPS the rest silently. So the send is now paced by how long the text takes to key, from PARIS timing at the rig's own speed. A long macro goes out complete instead of losing its tail. That leaves the question the log cannot answer: whether KY <text>; itself is accepted. If the rig also replies "?;" to it, the CAT menu's PC KEYING setting is the next suspect — but nothing in the code will be guessing at it.
226 lines
7.5 KiB
Go
226 lines
7.5 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 + ";")
|
|
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
|
|
}
|
|
|
|
// 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()), " ")
|
|
}
|