feat: serial-line CW keyer (DTR=CW / RTS=PTT) — SCU-17 / N1MM style
Adds a 4th CW keyer engine alongside K1EL WinKeyer, Icom CI-V and Flex CWX:
OpsLog bit-bangs the Morse itself on a serial control line (DTR keys CW, RTS
keys PTT — swappable), the hardware-keying method N1MM/WSJT use with a Yaesu
SCU-17 and generic interfaces. No WinKeyer chip needed.
- internal/winkeyer/serialcw.go: serialKeyer — worker goroutine, morse table,
WPM + Farnsworth timing (live speed changes), abortable, PTT lead-in/tail/hang.
- winkeyer.Config gains Type ("k1el"|"serial") + CWKeyLine ("dtr"|"rts");
Manager routes Connect/Send/Stop/SetSpeed/Backspace/Disconnect to it.
- app.go: keyWKCWLine setting; WinkeyerConnect sets Type from the engine.
- Settings -> CW Keyer: new "Serial port (DTR=CW/RTS=PTT)" engine with COM port,
key-line selector, speed/Farnsworth/lead-in/tail/PTT. Macros, auto-CQ and
<LOGQSO> reuse the WinKeyer path unchanged.
UNTESTED on hardware (no SCU-17 to hand): polarity assumes N1MM convention
(line HIGH = active). Weight not yet honoured; Farnsworth is.
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
package winkeyer
|
||||
|
||||
// serialcw.go — CW keying by toggling a serial control line, the "hardware CW
|
||||
// keying" method N1MM / WSJT / fldigi use with a Yaesu SCU-17 and similar
|
||||
// interfaces (DTR = CW key, RTS = PTT). No K1EL WinKeyer chip involved: the PC
|
||||
// bit-bangs the Morse itself on the DTR (or RTS) line at the configured WPM.
|
||||
//
|
||||
// A single worker goroutine consumes queued text and keys it element by element,
|
||||
// so Send returns immediately (like the WinKeyer's buffered send) and Stop can
|
||||
// abort mid-message. Speed changes apply live between characters. PTT (when
|
||||
// enabled) is asserted on the OTHER line for the whole transmission plus a
|
||||
// lead-in / tail, and held through a short hang so send-on-type doesn't chatter.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// morseTable maps a keyable character to its Morse element string (. = dit,
|
||||
// - = dah). Mirrors winkeyer.allowedCW.
|
||||
var morseTable = map[rune]string{
|
||||
'A': ".-", 'B': "-...", 'C': "-.-.", 'D': "-..", 'E': ".", 'F': "..-.",
|
||||
'G': "--.", 'H': "....", 'I': "..", 'J': ".---", 'K': "-.-", 'L': ".-..",
|
||||
'M': "--", 'N': "-.", 'O': "---", 'P': ".--.", 'Q': "--.-", 'R': ".-.",
|
||||
'S': "...", 'T': "-", 'U': "..-", 'V': "...-", 'W': ".--", 'X': "-..-",
|
||||
'Y': "-.--", 'Z': "--..",
|
||||
'0': "-----", '1': ".----", '2': "..---", '3': "...--", '4': "....-",
|
||||
'5': ".....", '6': "-....", '7': "--...", '8': "---..", '9': "----.",
|
||||
'.': ".-.-.-", ',': "--..--", '?': "..--..", '/': "-..-.", '=': "-...-",
|
||||
'+': ".-.-.", '-': "-....-", ':': "---...", '(': "-.--.", ')': "-.--.-",
|
||||
';': "-.-.-.", '"': ".-..-.", '\'': ".----.", '@': ".--.-.",
|
||||
}
|
||||
|
||||
// serialKeyer bit-bangs CW on a serial control line. Owned by a winkeyer.Manager
|
||||
// while Type == "serial"; all keying happens in run().
|
||||
type serialKeyer struct {
|
||||
port serial.Port
|
||||
keyDTR bool // true: CW on DTR, PTT on RTS (N1MM default). false: swapped.
|
||||
usePTT bool
|
||||
leadMs int
|
||||
tailMs int
|
||||
onBusy func(bool)
|
||||
|
||||
mu sync.Mutex
|
||||
wpm int
|
||||
farns int
|
||||
abort chan struct{} // recreated by Stop; keying aborts when it closes
|
||||
|
||||
in chan string
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newSerialKeyer(port serial.Port, cfg Config, onBusy func(bool)) *serialKeyer {
|
||||
k := &serialKeyer{
|
||||
port: port,
|
||||
keyDTR: !strings.EqualFold(strings.TrimSpace(cfg.CWKeyLine), "rts"), // default DTR=CW
|
||||
usePTT: cfg.UsePTT,
|
||||
leadMs: cfg.LeadInMs,
|
||||
tailMs: cfg.TailMs,
|
||||
onBusy: onBusy,
|
||||
wpm: cfg.WPM,
|
||||
farns: cfg.Farnsworth,
|
||||
abort: make(chan struct{}),
|
||||
in: make(chan string, 256),
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
k.allLow() // start idle: key up, PTT off
|
||||
go k.run()
|
||||
return k
|
||||
}
|
||||
|
||||
// setKey raises/lowers the CW key line; setPTT the PTT line (only when enabled).
|
||||
func (k *serialKeyer) setKey(down bool) {
|
||||
if k.keyDTR {
|
||||
_ = k.port.SetDTR(down)
|
||||
} else {
|
||||
_ = k.port.SetRTS(down)
|
||||
}
|
||||
}
|
||||
func (k *serialKeyer) setPTT(on bool) {
|
||||
if !k.usePTT {
|
||||
return
|
||||
}
|
||||
if k.keyDTR {
|
||||
_ = k.port.SetRTS(on)
|
||||
} else {
|
||||
_ = k.port.SetDTR(on)
|
||||
}
|
||||
}
|
||||
func (k *serialKeyer) allLow() { _ = k.port.SetDTR(false); _ = k.port.SetRTS(false) }
|
||||
|
||||
// SetSpeed / Send / Stop mirror the WinKeyer manager's control surface.
|
||||
func (k *serialKeyer) SetSpeed(wpm int) {
|
||||
k.mu.Lock()
|
||||
k.wpm = wpm
|
||||
k.mu.Unlock()
|
||||
}
|
||||
|
||||
func (k *serialKeyer) Send(text string) {
|
||||
select {
|
||||
case k.in <- text:
|
||||
default: // queue full (pathological) — drop rather than block the app binding
|
||||
}
|
||||
}
|
||||
|
||||
// Stop aborts the character being keyed and flushes anything queued.
|
||||
func (k *serialKeyer) Stop() {
|
||||
k.mu.Lock()
|
||||
close(k.abort)
|
||||
k.abort = make(chan struct{})
|
||||
k.mu.Unlock()
|
||||
for drained := false; !drained; {
|
||||
select {
|
||||
case <-k.in:
|
||||
default:
|
||||
drained = true
|
||||
}
|
||||
}
|
||||
k.setKey(false)
|
||||
}
|
||||
|
||||
func (k *serialKeyer) Close() {
|
||||
close(k.stop)
|
||||
<-k.done
|
||||
}
|
||||
|
||||
func (k *serialKeyer) run() {
|
||||
defer close(k.done)
|
||||
defer k.allLow()
|
||||
for {
|
||||
select {
|
||||
case <-k.stop:
|
||||
return
|
||||
case s := <-k.in:
|
||||
k.onBusy(true)
|
||||
k.setPTT(true)
|
||||
k.sleep(time.Duration(k.leadMs) * time.Millisecond)
|
||||
k.keyText(s)
|
||||
hang := time.Duration(k.tailMs) * time.Millisecond
|
||||
if hang <= 0 {
|
||||
hang = 5 * time.Millisecond
|
||||
}
|
||||
hold: // hold PTT briefly so back-to-back sends (send-on-type) don't chatter
|
||||
for {
|
||||
select {
|
||||
case <-k.stop:
|
||||
k.allLow()
|
||||
k.onBusy(false)
|
||||
return
|
||||
case s2 := <-k.in:
|
||||
k.keyText(s2)
|
||||
case <-time.After(hang):
|
||||
break hold
|
||||
}
|
||||
}
|
||||
k.setPTT(false)
|
||||
k.onBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// keyText keys one string. Element gaps use the character speed (WPM); the
|
||||
// inter-character (3-unit) and inter-word (7-unit) gaps use the Farnsworth speed
|
||||
// when one is set, so slow-copy CW keeps full-speed characters with wider spacing.
|
||||
func (k *serialKeyer) keyText(s string) {
|
||||
k.mu.Lock()
|
||||
abort := k.abort
|
||||
k.mu.Unlock()
|
||||
for _, r := range strings.ToUpper(s) {
|
||||
select {
|
||||
case <-abort:
|
||||
k.setKey(false)
|
||||
return
|
||||
case <-k.stop:
|
||||
k.setKey(false)
|
||||
return
|
||||
default:
|
||||
}
|
||||
ditW, ditF := k.dits()
|
||||
if r == ' ' {
|
||||
// Word gap: 7 units total. A 3-unit inter-char gap was already emitted
|
||||
// after the previous character, so add 4 more.
|
||||
if !k.gap(4*ditF, abort) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
code, ok := morseTable[r]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for j, el := range code {
|
||||
if el == '.' {
|
||||
if !k.mark(ditW, abort) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if !k.mark(3*ditW, abort) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if j < len(code)-1 { // intra-character (element) gap: 1 unit
|
||||
if !k.gap(ditW, abort) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if !k.gap(3*ditF, abort) { // inter-character gap: 3 units
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mark holds the key down for d; gap holds it up for d. Both abort cleanly
|
||||
// (leaving the key UP) when Stop closes abort or the keyer shuts down.
|
||||
func (k *serialKeyer) mark(d time.Duration, abort chan struct{}) bool {
|
||||
k.setKey(true)
|
||||
ok := k.wait(d, abort)
|
||||
k.setKey(false)
|
||||
return ok
|
||||
}
|
||||
func (k *serialKeyer) gap(d time.Duration, abort chan struct{}) bool { return k.wait(d, abort) }
|
||||
|
||||
func (k *serialKeyer) wait(d time.Duration, abort chan struct{}) bool {
|
||||
if d <= 0 {
|
||||
return true
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
return true
|
||||
case <-abort:
|
||||
return false
|
||||
case <-k.stop:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sleep is a non-abortable pause (used for the short PTT lead-in).
|
||||
func (k *serialKeyer) sleep(d time.Duration) {
|
||||
if d <= 0 {
|
||||
return
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
case <-k.stop:
|
||||
}
|
||||
}
|
||||
|
||||
// dits returns the element (character-speed) dit and the spacing (Farnsworth)
|
||||
// dit as durations, read live so a speed change mid-message takes effect.
|
||||
func (k *serialKeyer) dits() (elem, space time.Duration) {
|
||||
k.mu.Lock()
|
||||
w, f := k.wpm, k.farns
|
||||
k.mu.Unlock()
|
||||
if w < 5 {
|
||||
w = 5
|
||||
}
|
||||
if w > 99 {
|
||||
w = 99
|
||||
}
|
||||
elem = time.Duration(float64(time.Millisecond) * 1200.0 / float64(w))
|
||||
space = elem
|
||||
if f > 0 && f < w {
|
||||
space = time.Duration(float64(time.Millisecond) * 1200.0 / float64(f))
|
||||
}
|
||||
return elem, space
|
||||
}
|
||||
@@ -49,6 +49,15 @@ type Config struct {
|
||||
AutoSpace bool `json:"autospace"` // auto letter-space
|
||||
UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output)
|
||||
SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host
|
||||
|
||||
// Type selects the keyer engine on this serial port:
|
||||
// "" / "k1el" → a K1EL WinKeyer chip (the default, everything above applies)
|
||||
// "serial" → the PC bit-bangs Morse on a control line (no WinKeyer chip):
|
||||
// the "hardware CW keying" a Yaesu SCU-17 / generic interface
|
||||
// uses. WPM / Weight / Farnsworth / LeadIn / Tail / UsePTT still
|
||||
// apply; the paddle/sidetone/ratio fields are WinKeyer-only.
|
||||
Type string `json:"type"`
|
||||
CWKeyLine string `json:"cw_key_line"` // serial: "dtr" (CW on DTR, PTT on RTS — default) | "rts"
|
||||
}
|
||||
|
||||
func (c Config) normalised() Config {
|
||||
@@ -93,6 +102,7 @@ type Manager struct {
|
||||
status Status
|
||||
stopRead chan struct{}
|
||||
doneRead chan struct{}
|
||||
serial *serialKeyer // non-nil when cfg.Type == "serial" (DTR/RTS line keying)
|
||||
|
||||
onStatus func(Status)
|
||||
onEcho func(string) // chars the device echoes back as it keys them
|
||||
@@ -130,6 +140,9 @@ func (m *Manager) Connect(cfg Config) error {
|
||||
if strings.TrimSpace(cfg.Port) == "" {
|
||||
return fmt.Errorf("winkeyer: no serial port selected")
|
||||
}
|
||||
if cfg.Type == "serial" {
|
||||
return m.connectSerial(cfg)
|
||||
}
|
||||
m.Disconnect() // drop any existing link first
|
||||
|
||||
p, err := serial.Open(cfg.Port, &serial.Mode{
|
||||
@@ -175,6 +188,47 @@ func (m *Manager) Connect(cfg Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectSerial opens the port for line-keying (DTR=CW / RTS=PTT) — no K1EL
|
||||
// handshake, no read loop. The PC bit-bangs the Morse itself (see serialcw.go).
|
||||
func (m *Manager) connectSerial(cfg Config) error {
|
||||
m.Disconnect() // drop any existing link first
|
||||
|
||||
p, err := serial.Open(cfg.Port, &serial.Mode{
|
||||
BaudRate: cfg.Baud,
|
||||
DataBits: 8,
|
||||
Parity: serial.NoParity,
|
||||
StopBits: serial.OneStopBit,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
|
||||
}
|
||||
// The keyer updates busy state as it keys; mirror it into status + notify.
|
||||
sk := newSerialKeyer(p, cfg, func(busy bool) {
|
||||
m.mu.Lock()
|
||||
changed := m.status.Busy != busy
|
||||
m.status.Busy = busy
|
||||
m.mu.Unlock()
|
||||
if changed {
|
||||
m.emit()
|
||||
}
|
||||
})
|
||||
|
||||
m.mu.Lock()
|
||||
m.port = p
|
||||
m.serial = sk
|
||||
m.cfg = cfg
|
||||
m.status = Status{Connected: true, WPM: cfg.WPM, Port: cfg.Port}
|
||||
m.mu.Unlock()
|
||||
|
||||
line := "DTR (CW) / RTS (PTT)"
|
||||
if strings.EqualFold(cfg.CWKeyLine, "rts") {
|
||||
line = "RTS (CW) / DTR (PTT)"
|
||||
}
|
||||
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v", cfg.Port, line, cfg.UsePTT)
|
||||
m.emit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyConfig pushes the keying parameters to the device.
|
||||
func (m *Manager) applyConfig(c Config) error {
|
||||
cmds := [][]byte{
|
||||
@@ -256,7 +310,12 @@ func (m *Manager) SetSpeed(wpm int) error {
|
||||
if wpm > 99 {
|
||||
wpm = 99
|
||||
}
|
||||
if err := m.write([]byte{0x02, byte(wpm)}); err != nil {
|
||||
m.mu.Lock()
|
||||
sk := m.serial
|
||||
m.mu.Unlock()
|
||||
if sk != nil {
|
||||
sk.SetSpeed(wpm)
|
||||
} else if err := m.write([]byte{0x02, byte(wpm)}); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
@@ -283,18 +342,39 @@ func (m *Manager) Send(text string) error {
|
||||
if out == "" {
|
||||
return nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
sk := m.serial
|
||||
m.mu.Unlock()
|
||||
if sk != nil {
|
||||
sk.Send(out)
|
||||
return nil
|
||||
}
|
||||
return m.write([]byte(out))
|
||||
}
|
||||
|
||||
// Stop aborts the current message and clears the keyer buffer (command 0x0A).
|
||||
func (m *Manager) Stop() error {
|
||||
m.mu.Lock()
|
||||
sk := m.serial
|
||||
m.mu.Unlock()
|
||||
if sk != nil {
|
||||
sk.Stop()
|
||||
return nil
|
||||
}
|
||||
return m.write([]byte{0x0A})
|
||||
}
|
||||
|
||||
// Backspace removes the most recent character from the keyer's send buffer,
|
||||
// IF it hasn't been keyed yet (command 0x08). Used by "send on typing" mode
|
||||
// so a fast typo can be corrected before it goes on the air.
|
||||
// so a fast typo can be corrected before it goes on the air. The serial line
|
||||
// keyer has no buffer to un-key from, so backspace is a no-op there.
|
||||
func (m *Manager) Backspace() error {
|
||||
m.mu.Lock()
|
||||
sk := m.serial
|
||||
m.mu.Unlock()
|
||||
if sk != nil {
|
||||
return nil
|
||||
}
|
||||
return m.write([]byte{0x08})
|
||||
}
|
||||
|
||||
@@ -313,14 +393,29 @@ func (m *Manager) write(b []byte) error {
|
||||
func (m *Manager) Disconnect() {
|
||||
m.mu.Lock()
|
||||
p := m.port
|
||||
sk := m.serial
|
||||
stop, done := m.stopRead, m.doneRead
|
||||
m.port = nil
|
||||
m.serial = nil
|
||||
m.stopRead = nil
|
||||
m.doneRead = nil
|
||||
connected := m.status.Connected
|
||||
m.status = Status{Connected: false}
|
||||
m.mu.Unlock()
|
||||
|
||||
if sk != nil {
|
||||
// Serial line keyer: stop the worker (drops the key/PTT lines) then close.
|
||||
sk.Close()
|
||||
if p != nil {
|
||||
_ = p.Close()
|
||||
}
|
||||
if connected {
|
||||
applog.Printf("winkeyer: serial CW keyer disconnected")
|
||||
m.emit()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if p != nil {
|
||||
_, _ = p.Write([]byte{0x00, 0x03}) // Host Close
|
||||
_ = p.Close()
|
||||
|
||||
Reference in New Issue
Block a user