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:
@@ -0,0 +1,37 @@
|
||||
//go:build !windows
|
||||
|
||||
package winkeyer
|
||||
|
||||
// linectl_other.go — non-Windows DTR/RTS line control via go.bug.st/serial. On
|
||||
// Linux/macOS the SetDTR/SetRTS ioctls control the lines independently, so the
|
||||
// CP210x-on-Windows problem that motivated the native path (see linectl_windows.go)
|
||||
// doesn't apply. OpsLog ships on Windows; this keeps the package building elsewhere.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
type serialLineCtl struct {
|
||||
port serial.Port
|
||||
}
|
||||
|
||||
func openLineCtl(port string) (lineCtl, error) {
|
||||
if strings.TrimSpace(port) == "" {
|
||||
return nil, fmt.Errorf("no serial port")
|
||||
}
|
||||
p, err := serial.Open(port, &serial.Mode{BaudRate: 9600, DataBits: 8, Parity: serial.NoParity, StopBits: serial.OneStopBit})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", port, err)
|
||||
}
|
||||
c := &serialLineCtl{port: p}
|
||||
_ = c.setDTR(false)
|
||||
_ = c.setRTS(false)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *serialLineCtl) setDTR(on bool) error { return c.port.SetDTR(on) }
|
||||
func (c *serialLineCtl) setRTS(on bool) error { return c.port.SetRTS(on) }
|
||||
func (c *serialLineCtl) close() error { return c.port.Close() }
|
||||
@@ -0,0 +1,81 @@
|
||||
//go:build windows
|
||||
|
||||
package winkeyer
|
||||
|
||||
// linectl_windows.go — DTR/RTS line control via EscapeCommFunction, the direct
|
||||
// Win32 method N1MM/WSJT/fldigi use. It sets ONE line at a time without a
|
||||
// GetCommState/SetCommState round-trip, so a held RTS (PTT) is NOT disturbed when
|
||||
// DTR (CW) is toggled. go.bug.st/serial drives the lines via SetCommState, which
|
||||
// on USB-serial adapters (the SCU-17's CP210x, FTDI) drops the held RTS the moment
|
||||
// DTR changes — the "rig drops to RX between words" bug this fixes.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type winLineCtl struct {
|
||||
h windows.Handle
|
||||
}
|
||||
|
||||
// openLineCtl opens a COM port for line control only (no data I/O).
|
||||
func openLineCtl(port string) (lineCtl, error) {
|
||||
name := strings.TrimSpace(port)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("no serial port")
|
||||
}
|
||||
// The \\.\ prefix is required for COM10+ and harmless for COM1-9.
|
||||
if !strings.HasPrefix(name, `\\.\`) {
|
||||
name = `\\.\` + name
|
||||
}
|
||||
p, err := windows.UTF16PtrFromString(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h, err := windows.CreateFile(p,
|
||||
windows.GENERIC_READ|windows.GENERIC_WRITE, 0, nil,
|
||||
windows.OPEN_EXISTING, 0, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", port, err)
|
||||
}
|
||||
// A minimal DCB so the driver is in a sane state. Baud is irrelevant for pure
|
||||
// line control, but leaving fDtr/fRtsControl at DISABLE means only our explicit
|
||||
// EscapeCommFunction calls drive the lines.
|
||||
var dcb windows.DCB
|
||||
if windows.GetCommState(h, &dcb) == nil {
|
||||
dcb.BaudRate = 9600
|
||||
dcb.ByteSize = 8
|
||||
dcb.Parity = 0 // NOPARITY
|
||||
dcb.StopBits = 0 // ONESTOPBIT
|
||||
// Clear DTR (bits 4-5) and RTS (bits 12-13) control fields → DISABLE, so the
|
||||
// lines idle low until we assert them, and no hardware flow control fights us.
|
||||
dcb.Flags &^= 0x00000030
|
||||
dcb.Flags &^= 0x00003000
|
||||
_ = windows.SetCommState(h, &dcb)
|
||||
}
|
||||
c := &winLineCtl{h: h}
|
||||
// Start both lines low (inactive) — the keyer's idle() will do this too.
|
||||
_ = c.setDTR(false)
|
||||
_ = c.setRTS(false)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *winLineCtl) setDTR(on bool) error {
|
||||
f := uint32(windows.CLRDTR)
|
||||
if on {
|
||||
f = windows.SETDTR
|
||||
}
|
||||
return windows.EscapeCommFunction(c.h, f)
|
||||
}
|
||||
|
||||
func (c *winLineCtl) setRTS(on bool) error {
|
||||
f := uint32(windows.CLRRTS)
|
||||
if on {
|
||||
f = windows.SETRTS
|
||||
}
|
||||
return windows.EscapeCommFunction(c.h, f)
|
||||
}
|
||||
|
||||
func (c *winLineCtl) close() error { return windows.CloseHandle(c.h) }
|
||||
@@ -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() {
|
||||
|
||||
@@ -209,17 +209,14 @@ func (m *Manager) ApplySerialConfig(cfg Config) {
|
||||
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,
|
||||
})
|
||||
// Open for line control only (DTR/RTS). On Windows this uses EscapeCommFunction
|
||||
// so a held RTS (PTT) survives DTR (CW) toggling on USB adapters — see linectl_*.
|
||||
line, err := openLineCtl(cfg.Port)
|
||||
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) {
|
||||
sk := newSerialKeyer(line, cfg, func(busy bool) {
|
||||
m.mu.Lock()
|
||||
changed := m.status.Busy != busy
|
||||
m.status.Busy = busy
|
||||
@@ -230,17 +227,17 @@ func (m *Manager) connectSerial(cfg Config) error {
|
||||
})
|
||||
|
||||
m.mu.Lock()
|
||||
m.port = p
|
||||
m.port = nil // serial keyer owns its own (line-only) port, not m.port
|
||||
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)"
|
||||
lineDesc := "DTR (CW) / RTS (PTT)"
|
||||
if strings.EqualFold(cfg.CWKeyLine, "rts") {
|
||||
line = "RTS (CW) / DTR (PTT)"
|
||||
lineDesc = "RTS (CW) / DTR (PTT)"
|
||||
}
|
||||
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v", cfg.Port, line, cfg.UsePTT)
|
||||
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v (EscapeCommFunction line ctl)", cfg.Port, lineDesc, cfg.UsePTT)
|
||||
m.emit()
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user