Files
OpsLog/internal/winkeyer/serialcw.go
T
rouggy 43f15f1a2c feat: serial CW keyer — add invert (active-LOW) polarity option
Some CW interfaces key on the opposite line level (transmit at rest / key
backwards). New "Invert keying (active-LOW)" toggle flips the asserted level for
both the CW and PTT lines, and the idle state drives both lines to their
inactive level accordingly so an active-low rig isn't left keyed at rest.
Default off = N1MM convention (line HIGH = active).
2026-07-23 18:50:01 +02:00

289 lines
7.3 KiB
Go

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.
invert bool // true: active-LOW — asserting a line means driving it LOW
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
invert: cfg.CWInvert,
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.idle() // start inactive: key up, PTT off
go k.run()
return k
}
// level maps a logical "active" state to the physical line level, honouring the
// invert (active-LOW) option: normally active = HIGH, inverted active = LOW.
func (k *serialKeyer) level(active bool) bool { return active != k.invert }
// 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(k.level(down))
} else {
_ = k.port.SetRTS(k.level(down))
}
}
func (k *serialKeyer) setPTT(on bool) {
if !k.usePTT {
return
}
if k.keyDTR {
_ = k.port.SetRTS(k.level(on))
} else {
_ = k.port.SetDTR(k.level(on))
}
}
// idle drives BOTH lines to their inactive level (key up, PTT off), respecting
// the invert option — so an active-LOW interface isn't left keyed at rest.
func (k *serialKeyer) idle() {
_ = k.port.SetDTR(k.level(false))
_ = k.port.SetRTS(k.level(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.idle()
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.idle()
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
}