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 ; 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 ;, // 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()), " ") }