fix: serial CW keyer PTT — default on, live-apply, and diagnostic log

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.
This commit is contained in:
2026-07-23 20:27:05 +02:00
parent a71e48f811
commit 89584f173d
5 changed files with 72 additions and 18 deletions
+44 -17
View File
@@ -14,9 +14,12 @@ package winkeyer
import (
"strings"
"sync"
"sync/atomic"
"time"
"go.bug.st/serial"
"hamlog/internal/applog"
)
// morseTable maps a keyable character to its Morse element string (. = dit,
@@ -35,14 +38,16 @@ var morseTable = map[rune]string{
}
// serialKeyer bit-bangs CW on a serial control line. Owned by a winkeyer.Manager
// while Type == "serial"; all keying happens in run().
// 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 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
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
@@ -58,11 +63,6 @@ type serialKeyer 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,
@@ -71,28 +71,49 @@ func newSerialKeyer(port serial.Port, cfg Config, onBusy func(bool)) *serialKeye
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 }
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 {
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 {
if !k.usePTT.Load() {
return
}
if k.keyDTR {
if k.keyDTR.Load() {
_ = k.port.SetRTS(k.level(on))
} else {
_ = k.port.SetDTR(k.level(on))
@@ -150,10 +171,16 @@ func (k *serialKeyer) run() {
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) * time.Millisecond)
k.sleep(time.Duration(k.leadMs.Load()) * time.Millisecond)
k.keyText(s)
hang := time.Duration(k.tailMs) * time.Millisecond
hang := time.Duration(k.tailMs.Load()) * time.Millisecond
if hang <= 0 {
hang = 5 * time.Millisecond
}