package cat // CW keying through the Kenwood/Elecraft keyer — the KY command. // // Same idea as the Yaesu KY engine and the Icom / Flex keyers: the radio holds // the text and keys it with its own timing, so an Elecraft K3 (or any rig that // speaks this dialect) needs NO WinKeyer and NO second COM port — the single CAT // link does frequency, mode AND CW. That matters on a K3, whose one USB port is // the CAT port; a separate serial keyer would need a second cable OpsLog can't // give it. // // KY; → KYn; n=0 buffer has room, n=1 buffer full // KY ; queue up to 24 characters (the space after KY is part // of the command, not padding) // KS nnn; keyer speed in WPM (three digits) // RX; drop to receive — used to abort a send // // KY is the Elecraft-documented CW-over-CAT path on the K3/K4. A rig that refuses // it answers "?;", which SendCW turns into a stated reason rather than silence. import ( "fmt" "strings" "time" ) // kenwoodCWChunk is the most characters one KY command accepts. const kenwoodCWChunk = 24 // kenwoodCWAllowed is what the keyer can send; anything else is dropped, since an // unsupported byte can abort the buffer and lose the rest of the message. const kenwoodCWAllowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+:;()" // SendCW queues a message on the rig's keyer, fed in 24-character pieces, waiting // for buffer room between pieces so a long macro doesn't lose its tail. func (k *Kenwood) SendCW(text string) error { msg := filterKenwoodCW(text) if msg == "" { return nil } k.mu.Lock() defer k.mu.Unlock() if k.port == nil { return fmt.Errorf("kenwood: not connected") } for len(msg) > 0 { n := kenwoodCWChunk if len(msg) < n { n = len(msg) } chunk := msg[:n] msg = msg[n:] k.waitCWBuffer(3 * time.Second) if err := k.write("KY " + chunk + ";"); err != nil { return err } if err := k.afterKY(); err != nil { return err } // Pace the next piece by how long this one takes to key, so we never overrun // the 24-character buffer (the rig silently drops what doesn't fit). if len(msg) > 0 { time.Sleep(kenwoodCWDuration(chunk, k.keyerWPM())) } } return nil } // afterKY reads briefly after a KY write. An accepted KY says nothing; a REJECT // answers "?;". Reading it straight off the port (not through the shared rx // buffer) keeps that stray frame from being picked up by the next poll's ask — // which would mis-mark an unrelated command unsupported and desync the link — and // turns a silent non-transmission into a stated reason. The caller holds k.mu. func (k *Kenwood) afterKY() error { deadline := time.Now().Add(150 * time.Millisecond) tmp := make([]byte, 64) var buf []byte for time.Now().Before(deadline) { n, err := k.port.Read(tmp) if err != nil { break } if n > 0 { buf = append(buf, tmp[:n]...) } } if strings.Contains(string(buf), "?;") { return fmt.Errorf("this radio rejected CW over CAT (it answered \"?;\" to KY). " + "Switch the keyer engine to the serial-port keyer (DTR=CW) on a COM port instead") } return nil } // waitCWBuffer blocks until the keyer reports room, or the deadline passes. A rig // that never answers the KY; status query is not a reason to refuse to send — we // go ahead, and the per-chunk pacing covers the worst case. The caller holds k.mu. func (k *Kenwood) waitCWBuffer(within time.Duration) { deadline := time.Now().Add(within) for time.Now().Before(deadline) { if k.unsupported["KY"] { return // this rig doesn't report buffer state — pacing covers it } r, err := k.ask("KY;") if err != nil { return // unsupported / timeout — send anyway, pacing covers it } if !kenwoodCWBufferFull(r) { return } time.Sleep(50 * time.Millisecond) } } // kenwoodCWBufferFull reads the KY; status reply. Deliberately asymmetric: only a // clear "1" after KY means full. Anything else reads as "go ahead" — refusing to // send because a status line was phrased unexpectedly is the worse failure. func kenwoodCWBufferFull(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 } } return false } // StopCW aborts the message being sent. Kenwood documents no KY buffer-clear, so // this drops the transmitter — RX; forces receive, which is what an operator // pressing Escape wants; anything still queued is not keyed on the air. func (k *Kenwood) StopCW() error { k.mu.Lock() defer k.mu.Unlock() if k.port == nil { return fmt.Errorf("kenwood: not connected") } return k.write("RX;") } // SetKeySpeed sets the keyer speed (WPM) via KS and remembers it for pacing. func (k *Kenwood) SetKeySpeed(wpm int) error { if wpm < 4 { wpm = 4 } if wpm > 99 { wpm = 99 // KS is three digits but the K3 keyer tops out well below 100 } k.mu.Lock() defer k.mu.Unlock() k.keyWPM = wpm if k.port == nil { return fmt.Errorf("kenwood: not connected") } return k.write(fmt.Sprintf("KS%03d;", wpm)) } // keyerWPM is the speed to pace the buffer by. The caller holds k.mu. func (k *Kenwood) keyerWPM() int { if k.keyWPM >= 4 { return k.keyWPM } return 20 } // kenwoodCWDuration estimates how long a piece of text takes to key (PARIS // timing: a character averages 10 dits, a dit is 1.2/wpm seconds). func kenwoodCWDuration(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 } // filterKenwoodCW upper-cases and strips what the keyer cannot send; whitespace // becomes a single word gap. func filterKenwoodCW(text string) string { var b strings.Builder for _, r := range strings.ToUpper(text) { if r == '\t' || r == '\n' || r == '\r' { b.WriteByte(' ') continue } if strings.ContainsRune(kenwoodCWAllowed, r) { b.WriteRune(r) } } return strings.Join(strings.Fields(b.String()), " ") }