Reported: on a CQ macro the rig drops to RX between words instead of holding TX for the whole macro. Root cause is PTT not being held (usePTT off, or the RTS PTT line not wired/honoured). Improvements: - "Key PTT line" now defaults ON when the Serial engine is selected — without it the rig runs on semi-break-in and drops between words. - The serial keyer's line options (PTT, key line, invert, lead/tail, speed) are now atomic and live-updatable: SaveWinkeyerSettings -> ApplySerialConfig applies them from the next character, no reconnect needed. - Each transmission logs its PTT state / key line / lead/tail, so a rig that still won't hold TX can be diagnosed from the app log. The macro itself is sent as one string (not fragmented), so PTT is genuinely held across word gaps when usePTT is on — pending on-air confirmation via the log.
316 lines
8.8 KiB
Go
316 lines
8.8 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"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"go.bug.st/serial"
|
|
|
|
"hamlog/internal/applog"
|
|
)
|
|
|
|
// 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(). The line-control options
|
|
// (key line, invert, PTT, lead/tail) are atomic so ApplyConfig can update them
|
|
// LIVE — e.g. ticking "Key PTT line" takes effect without reconnecting the keyer.
|
|
type serialKeyer struct {
|
|
port serial.Port
|
|
keyDTR atomic.Bool // true: CW on DTR, PTT on RTS (N1MM default). false: swapped.
|
|
invert atomic.Bool // true: active-LOW — asserting a line means driving it LOW
|
|
usePTT atomic.Bool // hold the PTT line for the whole transmission
|
|
leadMs atomic.Int32 // PTT lead-in
|
|
tailMs atomic.Int32 // PTT hold (hang) after the last element
|
|
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,
|
|
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.keyDTR.Store(!strings.EqualFold(strings.TrimSpace(cfg.CWKeyLine), "rts")) // default DTR=CW
|
|
k.invert.Store(cfg.CWInvert)
|
|
k.usePTT.Store(cfg.UsePTT)
|
|
k.leadMs.Store(int32(cfg.LeadInMs))
|
|
k.tailMs.Store(int32(cfg.TailMs))
|
|
k.idle() // start inactive: key up, PTT off
|
|
go k.run()
|
|
return k
|
|
}
|
|
|
|
// ApplyConfig live-updates the line-control options (no port reopen), so a
|
|
// settings change is felt from the next character without a reconnect. Speed and
|
|
// Farnsworth are updated too. keyText re-reads these per element.
|
|
func (k *serialKeyer) ApplyConfig(cfg Config) {
|
|
k.keyDTR.Store(!strings.EqualFold(strings.TrimSpace(cfg.CWKeyLine), "rts"))
|
|
k.invert.Store(cfg.CWInvert)
|
|
k.usePTT.Store(cfg.UsePTT)
|
|
k.leadMs.Store(int32(cfg.LeadInMs))
|
|
k.tailMs.Store(int32(cfg.TailMs))
|
|
k.mu.Lock()
|
|
k.wpm = cfg.WPM
|
|
k.farns = cfg.Farnsworth
|
|
k.mu.Unlock()
|
|
k.idle() // re-assert idle lines with any new polarity/key-line choice
|
|
}
|
|
|
|
// 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.Load() }
|
|
|
|
// setKey raises/lowers the CW key line; setPTT the PTT line (only when enabled).
|
|
func (k *serialKeyer) setKey(down bool) {
|
|
if k.keyDTR.Load() {
|
|
_ = k.port.SetDTR(k.level(down))
|
|
} else {
|
|
_ = k.port.SetRTS(k.level(down))
|
|
}
|
|
}
|
|
func (k *serialKeyer) setPTT(on bool) {
|
|
if !k.usePTT.Load() {
|
|
return
|
|
}
|
|
if k.keyDTR.Load() {
|
|
_ = 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)
|
|
// Diagnostic: confirms whether PTT is actually held for the whole macro
|
|
// (a rig dropping to RX between words = usePTT off, or the interface's
|
|
// PTT line isn't wired / honoured in CW).
|
|
applog.Printf("winkeyer serial: TX %.40q ptt=%v line=%s lead=%dms tail=%dms",
|
|
s, k.usePTT.Load(), map[bool]string{true: "DTR-CW/RTS-PTT", false: "RTS-CW/DTR-PTT"}[k.keyDTR.Load()],
|
|
k.leadMs.Load(), k.tailMs.Load())
|
|
k.setPTT(true)
|
|
k.sleep(time.Duration(k.leadMs.Load()) * time.Millisecond)
|
|
k.keyText(s)
|
|
hang := time.Duration(k.tailMs.Load()) * 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
|
|
}
|