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