feat: serial-line CW keyer (DTR=CW / RTS=PTT) — SCU-17 / N1MM style

Adds a 4th CW keyer engine alongside K1EL WinKeyer, Icom CI-V and Flex CWX:
OpsLog bit-bangs the Morse itself on a serial control line (DTR keys CW, RTS
keys PTT — swappable), the hardware-keying method N1MM/WSJT use with a Yaesu
SCU-17 and generic interfaces. No WinKeyer chip needed.

- internal/winkeyer/serialcw.go: serialKeyer — worker goroutine, morse table,
  WPM + Farnsworth timing (live speed changes), abortable, PTT lead-in/tail/hang.
- winkeyer.Config gains Type ("k1el"|"serial") + CWKeyLine ("dtr"|"rts");
  Manager routes Connect/Send/Stop/SetSpeed/Backspace/Disconnect to it.
- app.go: keyWKCWLine setting; WinkeyerConnect sets Type from the engine.
- Settings -> CW Keyer: new "Serial port (DTR=CW/RTS=PTT)" engine with COM port,
  key-line selector, speed/Farnsworth/lead-in/tail/PTT. Macros, auto-CQ and
  <LOGQSO> reuse the WinKeyer path unchanged.

UNTESTED on hardware (no SCU-17 to hand): polarity assumes N1MM convention
(line HIGH = active). Weight not yet honoured; Farnsworth is.
This commit is contained in:
2026-07-23 17:56:11 +02:00
parent fcdc5809e6
commit 2c5416500f
6 changed files with 449 additions and 9 deletions
+14 -3
View File
@@ -214,9 +214,10 @@ const (
keyWKUsePTT = "winkeyer.use_ptt"
keyWKSerialEcho = "winkeyer.serial_echo"
keyWKMacros = "winkeyer.macros" // JSON array of {label,text}
keyWKEngine = "winkeyer.engine" // "winkeyer" | "icom" | "tci"
keyWKEngine = "winkeyer.engine" // "winkeyer" | "serial" | "icom" | "flex" | "tci"
keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign
keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed
keyWKCWLine = "winkeyer.cw_key_line" // serial engine: "dtr" (CW) / "rts" (PTT) or swapped
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
@@ -13052,13 +13053,16 @@ func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) {
keyWKEnabled, keyWKPort, keyWKBaud, keyWKWPM, keyWKWeight, keyWKLeadIn,
keyWKTail, keyWKRatio, keyWKFarnsworth, keyWKSidetone, keyWKMode,
keyWKSwap, keyWKAutoSpace, keyWKUsePTT, keyWKSerialEcho, keyWKMacros,
keyWKEngine, keyWKEscClears, keyWKSendOnType)
keyWKEngine, keyWKEscClears, keyWKSendOnType, keyWKCWLine)
if err != nil {
return out, err
}
if v := m[keyWKEngine]; v != "" {
out.Engine = v
}
if v := m[keyWKCWLine]; v != "" {
out.CWKeyLine = v
}
if v := m[keyWKEscClears]; v != "" {
out.EscClearsCall = v == "1"
}
@@ -13125,6 +13129,7 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
keyWKEngine: strings.TrimSpace(s.Engine),
keyWKEscClears: boolStr(s.EscClearsCall),
keyWKSendOnType: boolStr(s.SendOnType),
keyWKCWLine: strings.TrimSpace(s.CWKeyLine),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -13142,7 +13147,13 @@ func (a *App) WinkeyerConnect() error {
if err != nil {
return err
}
return a.winkeyer.Connect(s.Config)
cfg := s.Config
// The "serial" engine keys CW on the port's DTR/RTS lines (SCU-17 style)
// instead of talking the K1EL WinKeyer protocol — flag it for the manager.
if s.Engine == "serial" {
cfg.Type = "serial"
}
return a.winkeyer.Connect(cfg)
}
// WinkeyerDisconnect closes the serial link.