chore: release v0.23.3
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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()), " ")
|
||||
}
|
||||
Reference in New Issue
Block a user