chore: release v0.23.3
This commit is contained in:
@@ -869,3 +869,22 @@ func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
|
||||
return fn(yc)
|
||||
})
|
||||
}
|
||||
|
||||
// KenwoodController is the Kenwood/Elecraft CW-over-CAT capability (the KY keyer),
|
||||
// so a K3 can key CW through its single CAT link instead of a second COM port.
|
||||
type KenwoodController interface {
|
||||
SendCW(string) error
|
||||
StopCW() error
|
||||
SetKeySpeed(int) error
|
||||
}
|
||||
|
||||
// KenwoodDo dispatches a Kenwood control onto the CAT goroutine.
|
||||
func (m *Manager) KenwoodDo(fn func(KenwoodController) error) error {
|
||||
return m.exec(func(b Backend) error {
|
||||
kc, ok := b.(KenwoodController)
|
||||
if !ok {
|
||||
return fmt.Errorf("active CAT backend is not a Kenwood/Elecraft")
|
||||
}
|
||||
return fn(kc)
|
||||
})
|
||||
}
|
||||
|
||||
+38
-16
@@ -70,6 +70,15 @@ type Kenwood struct {
|
||||
curFreq int64
|
||||
curRXFreq int64
|
||||
curVFO string // "A" or "B"
|
||||
// Split is confirmed with FR;/FT; because IF's split bit is empty on some
|
||||
// Kenwood-dialect rigs — but that check is THROTTLED so it doesn't run every
|
||||
// poll: the two extra commands tripled the poll time on a slow K3 and made the
|
||||
// frequency (read from IF at the top of the poll) lag by seconds. Between
|
||||
// checks the last result stands.
|
||||
splitCheckAt time.Time
|
||||
splitCached bool
|
||||
splitVFOCached string
|
||||
keyWPM int // CW keyer speed, for pacing the KY buffer (see kenwood_cw.go)
|
||||
// Commands this rig answered "?;" to — asked once, then never again.
|
||||
unsupported map[string]bool
|
||||
|
||||
@@ -269,18 +278,31 @@ func (k *Kenwood) ReadState() (RigState, error) {
|
||||
// this costs two short commands per poll only where it actually works.
|
||||
split := f.Split
|
||||
if !split {
|
||||
rxv, rxOK := k.askVFO("FR;")
|
||||
txv, txOK := k.askVFO("FT;")
|
||||
if rxOK && txOK && rxv != txv {
|
||||
// Confirm with FR;/FT; only once a second, not every poll. Running the two
|
||||
// extra commands each cycle tripled the poll time on a slow K3, so the
|
||||
// frequency — already read from IF above — only surfaced every couple of
|
||||
// seconds. Between checks the last FR/FT result stands.
|
||||
if time.Since(k.splitCheckAt) >= time.Second {
|
||||
k.splitCheckAt = time.Now()
|
||||
k.splitCached, k.splitVFOCached = false, ""
|
||||
rxv, rxOK := k.askVFO("FR;")
|
||||
txv, txOK := k.askVFO("FT;")
|
||||
if rxOK && txOK && rxv != txv {
|
||||
k.splitCached = true
|
||||
// Trust FR over IF for which VFO is in use: they were asked in the
|
||||
// same breath, and a rig that leaves the split bit empty may be just
|
||||
// as vague about the VFO field. The block below picks the TRANSMIT
|
||||
// VFO as "the other one" from f.VFO, so leaving them disagreeing
|
||||
// would read the transmit frequency off the wrong dial.
|
||||
if rxv == "A" || rxv == "B" {
|
||||
k.splitVFOCached = rxv
|
||||
}
|
||||
}
|
||||
}
|
||||
if k.splitCached {
|
||||
split = true
|
||||
// Trust FR over IF for which VFO is in use: they were asked in the
|
||||
// same breath, and a rig that leaves the split bit empty may be just
|
||||
// as vague about the VFO field.
|
||||
// f.VFO too, not just the reported state: the block below picks the
|
||||
// TRANSMIT VFO as "the other one" from f.VFO, and leaving the two
|
||||
// disagreeing would read the transmit frequency off the wrong dial.
|
||||
if rxv == "A" || rxv == "B" {
|
||||
k.curVFO, s.Vfo, f.VFO = rxv, rxv, rxv
|
||||
if k.splitVFOCached != "" {
|
||||
k.curVFO, s.Vfo, f.VFO = k.splitVFOCached, k.splitVFOCached, k.splitVFOCached
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -529,11 +551,11 @@ func kenwoodModeDigit(mode string, hz int64) byte {
|
||||
}
|
||||
return '2'
|
||||
}
|
||||
// Any other digital mode rides on the data sideband, which on Kenwood is
|
||||
// plain USB/LSB with the rig's DATA input selected.
|
||||
if hz > 0 && hz < 10_000_000 {
|
||||
return '1'
|
||||
}
|
||||
// Any other digital mode (FT8, PSK, JT…) rides the DATA input, and by universal
|
||||
// convention that is ALWAYS the UPPER sideband, on every band. The SSB rule
|
||||
// (LSB below 10 MHz) must NOT be applied here: doing so put a 40/80 m data spot
|
||||
// on LSB, which an Elecraft K3 shows as "DATA REV" — the reversed sideband.
|
||||
// (RTTY/FSK is handled above as its own FSK mode, so it isn't affected.)
|
||||
return '2'
|
||||
}
|
||||
|
||||
|
||||
@@ -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()), " ")
|
||||
}
|
||||
@@ -300,6 +300,11 @@ func TestKenwoodSplitFromFRFT(t *testing.T) {
|
||||
|
||||
// Split on, IF still silent about it: receive on A, transmit on B.
|
||||
rig.split = true
|
||||
// The FR/FT split re-check is throttled to once a second (it tripled the poll
|
||||
// time on a slow K3), so back-to-back reads reuse the last result. Simulate the
|
||||
// recheck window having elapsed — in the field the 250 ms poll covers it inside
|
||||
// a second.
|
||||
k.splitCheckAt = time.Time{}
|
||||
if s, err = k.ReadState(); err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ func TestKenwoodModeDigit(t *testing.T) {
|
||||
{"RTTY", 14080000, '6'},
|
||||
{"AM", 7150000, '5'},
|
||||
{"FM", 145000000, '4'},
|
||||
{"FT8", 7074000, '1'}, // data rides on the sideband for the band
|
||||
{"FT8", 14074000, '2'},
|
||||
{"FT8", 7074000, '2'}, // data is ALWAYS USB, even below 10 MHz (K3 "DATA REV" otherwise)
|
||||
{"FT8", 14074000, '2'}, // …and above
|
||||
{"", 14074000, 0}, // nothing to set
|
||||
}
|
||||
for _, c := range cases {
|
||||
|
||||
Reference in New Issue
Block a user