fix: serial CW keyer PTT dropping between words on CP210x (SCU-17)

Confirmed from HB9HBY's log: ptt=true, yet the FT-891 dropped to RX between
words during a CQ macro — while the same SCU-17 works in N1MM. Root cause:
go.bug.st/serial drives DTR/RTS through GetCommState/SetCommState, which on the
SCU-17's Silicon Labs CP210x drops the asserted RTS (PTT) the moment DTR (CW)
is toggled. N1MM/WSJT use EscapeCommFunction, which sets one line without
disturbing the other.

OpsLog now drives the lines the same way: a new lineCtl abstraction with a
Windows implementation (linectl_windows.go) that opens the COM port via
CreateFile and toggles DTR/RTS with windows.EscapeCommFunction (SETDTR/CLRDTR/
SETRTS/CLRRTS). linectl_other.go keeps go.bug.st/serial for non-Windows builds.
The serial keyer holds a lineCtl instead of a serial.Port. RTS (PTT) now stays
asserted for the whole macro, so the rig holds TX between words.

Promotes golang.org/x/sys to a direct dependency.
This commit is contained in:
2026-07-23 21:35:59 +02:00
parent 89584f173d
commit aa871a07b7
5 changed files with 150 additions and 24 deletions
+22 -11
View File
@@ -17,11 +17,21 @@ import (
"sync/atomic"
"time"
"go.bug.st/serial"
"hamlog/internal/applog"
)
// lineCtl drives a serial port's DTR and RTS control lines. On Windows it uses
// EscapeCommFunction (SETDTR/CLRDTR/SETRTS/CLRRTS) — the direct method N1MM/WSJT
// use, reliable on USB-serial adapters (CP210x/FTDI). go.bug.st/serial drives the
// lines via SetCommState instead, which on a CP210x drops the held RTS (PTT) as
// soon as DTR (CW) is toggled — the "rig drops to RX between words" bug. See
// linectl_windows.go / linectl_other.go.
type lineCtl interface {
setDTR(on bool) error
setRTS(on bool) error
close() error
}
// morseTable maps a keyable character to its Morse element string (. = dit,
// - = dah). Mirrors winkeyer.allowedCW.
var morseTable = map[rune]string{
@@ -42,7 +52,7 @@ var morseTable = map[rune]string{
// (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
line lineCtl
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
@@ -60,9 +70,9 @@ type serialKeyer struct {
done chan struct{}
}
func newSerialKeyer(port serial.Port, cfg Config, onBusy func(bool)) *serialKeyer {
func newSerialKeyer(line lineCtl, cfg Config, onBusy func(bool)) *serialKeyer {
k := &serialKeyer{
port: port,
line: line,
onBusy: onBusy,
wpm: cfg.WPM,
farns: cfg.Farnsworth,
@@ -104,9 +114,9 @@ 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))
_ = k.line.setDTR(k.level(down))
} else {
_ = k.port.SetRTS(k.level(down))
_ = k.line.setRTS(k.level(down))
}
}
func (k *serialKeyer) setPTT(on bool) {
@@ -114,17 +124,17 @@ func (k *serialKeyer) setPTT(on bool) {
return
}
if k.keyDTR.Load() {
_ = k.port.SetRTS(k.level(on))
_ = k.line.setRTS(k.level(on))
} else {
_ = k.port.SetDTR(k.level(on))
_ = k.line.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))
_ = k.line.setDTR(k.level(false))
_ = k.line.setRTS(k.level(false))
}
// SetSpeed / Send / Stop mirror the WinKeyer manager's control surface.
@@ -160,6 +170,7 @@ func (k *serialKeyer) Stop() {
func (k *serialKeyer) Close() {
close(k.stop)
<-k.done
_ = k.line.close()
}
func (k *serialKeyer) run() {