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:
@@ -49,6 +49,15 @@ type Config struct {
|
||||
AutoSpace bool `json:"autospace"` // auto letter-space
|
||||
UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output)
|
||||
SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host
|
||||
|
||||
// Type selects the keyer engine on this serial port:
|
||||
// "" / "k1el" → a K1EL WinKeyer chip (the default, everything above applies)
|
||||
// "serial" → the PC bit-bangs Morse on a control line (no WinKeyer chip):
|
||||
// the "hardware CW keying" a Yaesu SCU-17 / generic interface
|
||||
// uses. WPM / Weight / Farnsworth / LeadIn / Tail / UsePTT still
|
||||
// apply; the paddle/sidetone/ratio fields are WinKeyer-only.
|
||||
Type string `json:"type"`
|
||||
CWKeyLine string `json:"cw_key_line"` // serial: "dtr" (CW on DTR, PTT on RTS — default) | "rts"
|
||||
}
|
||||
|
||||
func (c Config) normalised() Config {
|
||||
@@ -93,6 +102,7 @@ type Manager struct {
|
||||
status Status
|
||||
stopRead chan struct{}
|
||||
doneRead chan struct{}
|
||||
serial *serialKeyer // non-nil when cfg.Type == "serial" (DTR/RTS line keying)
|
||||
|
||||
onStatus func(Status)
|
||||
onEcho func(string) // chars the device echoes back as it keys them
|
||||
@@ -130,6 +140,9 @@ func (m *Manager) Connect(cfg Config) error {
|
||||
if strings.TrimSpace(cfg.Port) == "" {
|
||||
return fmt.Errorf("winkeyer: no serial port selected")
|
||||
}
|
||||
if cfg.Type == "serial" {
|
||||
return m.connectSerial(cfg)
|
||||
}
|
||||
m.Disconnect() // drop any existing link first
|
||||
|
||||
p, err := serial.Open(cfg.Port, &serial.Mode{
|
||||
@@ -175,6 +188,47 @@ func (m *Manager) Connect(cfg Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectSerial opens the port for line-keying (DTR=CW / RTS=PTT) — no K1EL
|
||||
// handshake, no read loop. The PC bit-bangs the Morse itself (see serialcw.go).
|
||||
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,
|
||||
})
|
||||
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) {
|
||||
m.mu.Lock()
|
||||
changed := m.status.Busy != busy
|
||||
m.status.Busy = busy
|
||||
m.mu.Unlock()
|
||||
if changed {
|
||||
m.emit()
|
||||
}
|
||||
})
|
||||
|
||||
m.mu.Lock()
|
||||
m.port = p
|
||||
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)"
|
||||
if strings.EqualFold(cfg.CWKeyLine, "rts") {
|
||||
line = "RTS (CW) / DTR (PTT)"
|
||||
}
|
||||
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v", cfg.Port, line, cfg.UsePTT)
|
||||
m.emit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyConfig pushes the keying parameters to the device.
|
||||
func (m *Manager) applyConfig(c Config) error {
|
||||
cmds := [][]byte{
|
||||
@@ -256,7 +310,12 @@ func (m *Manager) SetSpeed(wpm int) error {
|
||||
if wpm > 99 {
|
||||
wpm = 99
|
||||
}
|
||||
if err := m.write([]byte{0x02, byte(wpm)}); err != nil {
|
||||
m.mu.Lock()
|
||||
sk := m.serial
|
||||
m.mu.Unlock()
|
||||
if sk != nil {
|
||||
sk.SetSpeed(wpm)
|
||||
} else if err := m.write([]byte{0x02, byte(wpm)}); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
@@ -283,18 +342,39 @@ func (m *Manager) Send(text string) error {
|
||||
if out == "" {
|
||||
return nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
sk := m.serial
|
||||
m.mu.Unlock()
|
||||
if sk != nil {
|
||||
sk.Send(out)
|
||||
return nil
|
||||
}
|
||||
return m.write([]byte(out))
|
||||
}
|
||||
|
||||
// Stop aborts the current message and clears the keyer buffer (command 0x0A).
|
||||
func (m *Manager) Stop() error {
|
||||
m.mu.Lock()
|
||||
sk := m.serial
|
||||
m.mu.Unlock()
|
||||
if sk != nil {
|
||||
sk.Stop()
|
||||
return nil
|
||||
}
|
||||
return m.write([]byte{0x0A})
|
||||
}
|
||||
|
||||
// Backspace removes the most recent character from the keyer's send buffer,
|
||||
// IF it hasn't been keyed yet (command 0x08). Used by "send on typing" mode
|
||||
// so a fast typo can be corrected before it goes on the air.
|
||||
// so a fast typo can be corrected before it goes on the air. The serial line
|
||||
// keyer has no buffer to un-key from, so backspace is a no-op there.
|
||||
func (m *Manager) Backspace() error {
|
||||
m.mu.Lock()
|
||||
sk := m.serial
|
||||
m.mu.Unlock()
|
||||
if sk != nil {
|
||||
return nil
|
||||
}
|
||||
return m.write([]byte{0x08})
|
||||
}
|
||||
|
||||
@@ -313,14 +393,29 @@ func (m *Manager) write(b []byte) error {
|
||||
func (m *Manager) Disconnect() {
|
||||
m.mu.Lock()
|
||||
p := m.port
|
||||
sk := m.serial
|
||||
stop, done := m.stopRead, m.doneRead
|
||||
m.port = nil
|
||||
m.serial = nil
|
||||
m.stopRead = nil
|
||||
m.doneRead = nil
|
||||
connected := m.status.Connected
|
||||
m.status = Status{Connected: false}
|
||||
m.mu.Unlock()
|
||||
|
||||
if sk != nil {
|
||||
// Serial line keyer: stop the worker (drops the key/PTT lines) then close.
|
||||
sk.Close()
|
||||
if p != nil {
|
||||
_ = p.Close()
|
||||
}
|
||||
if connected {
|
||||
applog.Printf("winkeyer: serial CW keyer disconnected")
|
||||
m.emit()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if p != nil {
|
||||
_, _ = p.Write([]byte{0x00, 0x03}) // Host Close
|
||||
_ = p.Close()
|
||||
|
||||
Reference in New Issue
Block a user