199 lines
6.0 KiB
Go
199 lines
6.0 KiB
Go
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 <text>; 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()), " ")
|
|
}
|