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:
+2
-2
@@ -3,12 +3,12 @@
|
|||||||
"version": "0.20.12",
|
"version": "0.20.12",
|
||||||
"date": "2026-07-23",
|
"date": "2026-07-23",
|
||||||
"en": [
|
"en": [
|
||||||
"Serial CW keyer: 'Key PTT line' now defaults on when you pick the Serial engine (without it the rig drops to RX between words on semi-break-in), and changing the PTT / key-line / speed options now applies immediately without disconnecting the keyer. It also logs each transmission's PTT state to help diagnose a rig that still won't hold TX.",
|
"Serial CW keyer: fixed the rig dropping to RX between words during a macro (PTT not held). On USB-serial interfaces like the Yaesu SCU-17 (CP210x), the previous method of driving the DTR/RTS lines would drop the held RTS (PTT) as soon as DTR (CW) toggled. OpsLog now drives the lines with the direct Win32 method N1MM/WSJT use (EscapeCommFunction), so RTS stays asserted for the whole transmission. 'Key PTT line' also defaults on for the Serial engine, and PTT / key-line / speed changes apply immediately without reconnecting the keyer.",
|
||||||
"New (Settings → General): show the ClubLog 'Most Wanted' rank in the entry-strip band matrix. When on, a 'MW #rank' pill appears next to the DXCC entity name (1 = the most wanted entity), coloured hotter the more wanted it is. The list is fetched from ClubLog and personalised to your callsign, refreshed daily.",
|
"New (Settings → General): show the ClubLog 'Most Wanted' rank in the entry-strip band matrix. When on, a 'MW #rank' pill appears next to the DXCC entity name (1 = the most wanted entity), coloured hotter the more wanted it is. The list is fetched from ClubLog and personalised to your callsign, refreshed daily.",
|
||||||
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
|
"Fixed the colour theme sometimes resetting to light after an update/relaunch: an update can clear the WebView's localStorage, and the fallback that restores the theme from the local settings database gave up after ~2.4s — occasionally too soon during the brief startup window before that store is ready. It now keeps retrying until the store actually answers, so a dark theme is reliably restored."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Keyer CW série : « Key PTT line » est maintenant activé par défaut quand on choisit le moteur Série (sans lui la radio retombe en RX entre les mots en semi-break-in), et changer les options PTT / ligne de manip / vitesse s'applique immédiatement sans déconnecter le keyer. Il logue aussi l'état PTT de chaque émission pour diagnostiquer une radio qui ne tient toujours pas le TX.",
|
"Keyer CW série : correction de la radio qui retombait en RX entre les mots pendant une macro (PTT non tenu). Sur les interfaces USB-série comme le Yaesu SCU-17 (CP210x), l'ancienne méthode de pilotage des lignes DTR/RTS faisait retomber le RTS (PTT) dès que DTR (CW) basculait. OpsLog pilote maintenant les lignes avec la méthode Win32 directe qu'utilisent N1MM/WSJT (EscapeCommFunction), donc RTS reste asserté toute l'émission. « Key PTT line » est aussi activé par défaut pour le moteur Série, et les changements PTT / ligne / vitesse s'appliquent immédiatement sans reconnecter le keyer.",
|
||||||
"Nouveau (Réglages → Général) : afficher le rang « Most Wanted » de ClubLog dans la matrice de bandes de la barre de saisie. Activé, une pastille « MW #rang » apparaît à côté du nom de l'entité DXCC (1 = l'entité la plus recherchée), d'autant plus colorée qu'elle est recherchée. La liste est récupérée depuis ClubLog et personnalisée selon ton indicatif, rafraîchie quotidiennement.",
|
"Nouveau (Réglages → Général) : afficher le rang « Most Wanted » de ClubLog dans la matrice de bandes de la barre de saisie. Activé, une pastille « MW #rang » apparaît à côté du nom de l'entité DXCC (1 = l'entité la plus recherchée), d'autant plus colorée qu'elle est recherchée. La liste est récupérée depuis ClubLog et personnalisée selon ton indicatif, rafraîchie quotidiennement.",
|
||||||
"Correction du thème de couleur qui revenait parfois au clair après une mise à jour/relancement : une mise à jour peut vider le localStorage de la WebView, et le repli qui restaure le thème depuis la base de réglages locale abandonnait après ~2,4s — parfois trop tôt pendant le court instant de démarrage avant que ce store soit prêt. Il réessaie maintenant jusqu'à ce que le store réponde vraiment, donc un thème sombre est restauré de façon fiable."
|
"Correction du thème de couleur qui revenait parfois au clair après une mise à jour/relancement : une mise à jour peut vider le localStorage de la WebView, et le repli qui restaure le thème depuis la base de réglages locale abandonnait après ~2,4s — parfois trop tôt pendant le court instant de démarrage avant que ce store soit prêt. Il réessaie maintenant jusqu'à ce que le store réponde vraiment, donc un thème sombre est restauré de façon fiable."
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.bug.st/serial"
|
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
"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,
|
// morseTable maps a keyable character to its Morse element string (. = dit,
|
||||||
// - = dah). Mirrors winkeyer.allowedCW.
|
// - = dah). Mirrors winkeyer.allowedCW.
|
||||||
var morseTable = map[rune]string{
|
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
|
// (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.
|
// LIVE — e.g. ticking "Key PTT line" takes effect without reconnecting the keyer.
|
||||||
type serialKeyer struct {
|
type serialKeyer struct {
|
||||||
port serial.Port
|
line lineCtl
|
||||||
keyDTR atomic.Bool // true: CW on DTR, PTT on RTS (N1MM default). false: swapped.
|
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
|
invert atomic.Bool // true: active-LOW — asserting a line means driving it LOW
|
||||||
usePTT atomic.Bool // hold the PTT line for the whole transmission
|
usePTT atomic.Bool // hold the PTT line for the whole transmission
|
||||||
@@ -60,9 +70,9 @@ type serialKeyer struct {
|
|||||||
done chan 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{
|
k := &serialKeyer{
|
||||||
port: port,
|
line: line,
|
||||||
onBusy: onBusy,
|
onBusy: onBusy,
|
||||||
wpm: cfg.WPM,
|
wpm: cfg.WPM,
|
||||||
farns: cfg.Farnsworth,
|
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).
|
// setKey raises/lowers the CW key line; setPTT the PTT line (only when enabled).
|
||||||
func (k *serialKeyer) setKey(down bool) {
|
func (k *serialKeyer) setKey(down bool) {
|
||||||
if k.keyDTR.Load() {
|
if k.keyDTR.Load() {
|
||||||
_ = k.port.SetDTR(k.level(down))
|
_ = k.line.setDTR(k.level(down))
|
||||||
} else {
|
} else {
|
||||||
_ = k.port.SetRTS(k.level(down))
|
_ = k.line.setRTS(k.level(down))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func (k *serialKeyer) setPTT(on bool) {
|
func (k *serialKeyer) setPTT(on bool) {
|
||||||
@@ -114,17 +124,17 @@ func (k *serialKeyer) setPTT(on bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if k.keyDTR.Load() {
|
if k.keyDTR.Load() {
|
||||||
_ = k.port.SetRTS(k.level(on))
|
_ = k.line.setRTS(k.level(on))
|
||||||
} else {
|
} 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
|
// 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.
|
// the invert option — so an active-LOW interface isn't left keyed at rest.
|
||||||
func (k *serialKeyer) idle() {
|
func (k *serialKeyer) idle() {
|
||||||
_ = k.port.SetDTR(k.level(false))
|
_ = k.line.setDTR(k.level(false))
|
||||||
_ = k.port.SetRTS(k.level(false))
|
_ = k.line.setRTS(k.level(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSpeed / Send / Stop mirror the WinKeyer manager's control surface.
|
// SetSpeed / Send / Stop mirror the WinKeyer manager's control surface.
|
||||||
@@ -160,6 +170,7 @@ func (k *serialKeyer) Stop() {
|
|||||||
func (k *serialKeyer) Close() {
|
func (k *serialKeyer) Close() {
|
||||||
close(k.stop)
|
close(k.stop)
|
||||||
<-k.done
|
<-k.done
|
||||||
|
_ = k.line.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *serialKeyer) run() {
|
func (k *serialKeyer) run() {
|
||||||
|
|||||||
@@ -209,17 +209,14 @@ func (m *Manager) ApplySerialConfig(cfg Config) {
|
|||||||
func (m *Manager) connectSerial(cfg Config) error {
|
func (m *Manager) connectSerial(cfg Config) error {
|
||||||
m.Disconnect() // drop any existing link first
|
m.Disconnect() // drop any existing link first
|
||||||
|
|
||||||
p, err := serial.Open(cfg.Port, &serial.Mode{
|
// Open for line control only (DTR/RTS). On Windows this uses EscapeCommFunction
|
||||||
BaudRate: cfg.Baud,
|
// so a held RTS (PTT) survives DTR (CW) toggling on USB adapters — see linectl_*.
|
||||||
DataBits: 8,
|
line, err := openLineCtl(cfg.Port)
|
||||||
Parity: serial.NoParity,
|
|
||||||
StopBits: serial.OneStopBit,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
|
return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
|
||||||
}
|
}
|
||||||
// The keyer updates busy state as it keys; mirror it into status + notify.
|
// 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()
|
m.mu.Lock()
|
||||||
changed := m.status.Busy != busy
|
changed := m.status.Busy != busy
|
||||||
m.status.Busy = busy
|
m.status.Busy = busy
|
||||||
@@ -230,17 +227,17 @@ func (m *Manager) connectSerial(cfg Config) error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.port = p
|
m.port = nil // serial keyer owns its own (line-only) port, not m.port
|
||||||
m.serial = sk
|
m.serial = sk
|
||||||
m.cfg = cfg
|
m.cfg = cfg
|
||||||
m.status = Status{Connected: true, WPM: cfg.WPM, Port: cfg.Port}
|
m.status = Status{Connected: true, WPM: cfg.WPM, Port: cfg.Port}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
line := "DTR (CW) / RTS (PTT)"
|
lineDesc := "DTR (CW) / RTS (PTT)"
|
||||||
if strings.EqualFold(cfg.CWKeyLine, "rts") {
|
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()
|
m.emit()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user