Files
OpsLog/internal/winkeyer/winkeyer.go
T
rouggy 15fdbce22a fix: WinKeyer 2 sent the dit/dah ratio to the key-compensation command
The byte trace from a real WK2 settled in one line what no amount of reading
could: "TX 11 32". Command 0x11 is SET KEY COMPENSATION in milliseconds, not the
dit/dah ratio — that is 0x17. So a neutral ratio of 50 asked for 50 ms of extra
key-down on EVERY element. At 25 wpm a dit is 48 ms, so elements more than
doubled and ran into each other: the reported "it sends one element, then a long
pause".

The ratio now goes out as 0x17, and the compensation is explicitly set to 0 —
merely stopping the wrong command would leave an affected keyer misbehaving,
since it keeps the value in EEPROM until something writes over it.

The init sequence is now built by a separate function so the BYTES are testable,
and a test pins each command number. These numbers are the contract with the
hardware, and a wrong one produces a fault that cannot be diagnosed from the UI
at all — this one cost the operator weeks and needed a trace to find.

This is why the fix waited for the trace rather than being guessed: the plausible
guesses (mode register, sidetone, WK1-vs-WK2 differences) were all wrong, and any
of them shipped blind would have broken the keyers that work today.
2026-07-29 14:53:18 +02:00

632 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package winkeyer drives a K1EL WinKeyer (WK1/WK2/WK3) CW keyer over a
// serial port — the same hardware Log4OM, N1MM and fldigi talk to. It opens
// the host-mode interface, applies the operator's keying parameters (speed,
// weight, lead-in/tail, sidetone, paddle mode…), sends arbitrary text as
// Morse, and aborts mid-message on demand.
//
// Protocol reference: K1EL "WinKeyer USB / WK3 Interface Description". The
// host link is 1200 baud 8N1. Bytes 0x000x1F are commands; printable ASCII
// is keyed directly. The device streams status bytes back (busy/idle, the
// speed-pot value, and an echo of each character as it's sent) which we
// surface to the UI via the OnStatus callback.
package winkeyer
import (
"fmt"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"time"
"go.bug.st/serial"
"hamlog/internal/applog"
)
// Mode selects the paddle keying mode (WinKey "mode register" low bits).
type Mode string
const (
ModeIambicB Mode = "iambic_b"
ModeIambicA Mode = "iambic_a"
ModeUltimatic Mode = "ultimatic"
ModeBug Mode = "bug"
)
// Config is the keyer configuration the UI persists and applies on connect.
type Config struct {
Port string `json:"port"` // e.g. "COM6"
Baud int `json:"baud"` // 1200 for WK2, also fine for WK3
WPM int `json:"wpm"` // 5..99
Weight int `json:"weight"` // 10..90, 50 = normal
LeadInMs int `json:"lead_in_ms"` // PTT lead-in, 10 ms units sent to device
TailMs int `json:"tail_ms"` // PTT tail
Ratio int `json:"ratio"` // dah/dit ratio 33..66 (50 = 3:1)
Farnsworth int `json:"farnsworth"` // Farnsworth WPM (0 = off)
Sidetone int `json:"sidetone_hz"` // 0 = off; else target Hz (mapped to WK code)
Mode Mode `json:"mode"` // paddle mode
Swap bool `json:"swap"` // swap dit/dah paddles
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"
CWInvert bool `json:"cw_invert"` // serial: invert line polarity (active-LOW) for both key and PTT
}
func (c Config) normalised() Config {
if c.Baud <= 0 {
c.Baud = 1200
}
if c.WPM < 5 {
c.WPM = 20
}
if c.WPM > 99 {
c.WPM = 99
}
if c.Weight < 10 || c.Weight > 90 {
c.Weight = 50
}
if c.Ratio < 33 || c.Ratio > 66 {
c.Ratio = 50
}
switch c.Mode {
case ModeIambicA, ModeIambicB, ModeUltimatic, ModeBug:
default:
c.Mode = ModeIambicB
}
return c
}
// Status is pushed to the UI whenever the link state or keyer activity changes.
type Status struct {
Connected bool `json:"connected"`
Busy bool `json:"busy"` // device is currently sending CW
WPM int `json:"wpm"` // current speed (tracks the speed pot)
Version int `json:"version"` // host firmware version byte
Port string `json:"port"`
Error string `json:"error,omitempty"`
}
// Manager owns the serial link. Safe for concurrent use.
type Manager struct {
mu sync.Mutex
port serial.Port
cfg Config
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
}
func NewManager(onStatus func(Status), onEcho func(string)) *Manager {
return &Manager{onStatus: onStatus, onEcho: onEcho}
}
// ListPorts returns the available serial port names (COM3, COM6, …).
func ListPorts() ([]string, error) {
ports, err := serial.GetPortsList()
if err != nil {
return nil, err
}
return ports, nil
}
// Status returns a snapshot.
func (m *Manager) Snapshot() Status {
m.mu.Lock()
defer m.mu.Unlock()
return m.status
}
func (m *Manager) emit() {
if m.onStatus != nil {
m.onStatus(m.status)
}
}
// Connect opens the port, performs the host-open handshake and applies cfg.
func (m *Manager) Connect(cfg Config) error {
cfg = cfg.normalised()
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{
BaudRate: cfg.Baud,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
})
if err != nil {
return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
}
_ = p.SetReadTimeout(200 * time.Millisecond)
// Host Open: <0x00 0x02>. Device replies with its firmware version byte.
if _, err := p.Write([]byte{0x00, 0x02}); err != nil {
_ = p.Close()
return fmt.Errorf("winkeyer: host open: %w", err)
}
ver := 0
buf := make([]byte, 16)
_ = p.SetReadTimeout(1 * time.Second)
if n, _ := p.Read(buf); n > 0 {
ver = int(buf[0])
}
_ = p.SetReadTimeout(200 * time.Millisecond)
m.mu.Lock()
m.port = p
m.cfg = cfg
m.status = Status{Connected: true, WPM: cfg.WPM, Version: ver, Port: cfg.Port}
m.stopRead = make(chan struct{})
m.doneRead = make(chan struct{})
stop, done := m.stopRead, m.doneRead
m.mu.Unlock()
applog.Printf("winkeyer: connected on %s — %s", cfg.Port, firmwareFamily(ver))
go m.readLoop(p, stop, done)
if err := m.applyConfig(cfg); err != nil {
applog.Printf("winkeyer: applyConfig: %v", err)
}
m.emit()
return nil
}
// ApplySerialConfig live-updates a running serial-line keyer's control options
// (PTT keying, key line, invert, lead/tail, speed) WITHOUT reopening the port —
// so ticking "Key PTT line" (or changing the key line) takes effect from the next
// character, no reconnect needed. No-op unless a serial keyer is running.
func (m *Manager) ApplySerialConfig(cfg Config) {
cfg = cfg.normalised()
m.mu.Lock()
sk := m.serial
m.cfg = cfg
m.mu.Unlock()
if sk != nil {
sk.ApplyConfig(cfg)
}
}
// 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
// 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(line, 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 = 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()
lineDesc := "DTR (CW) / RTS (PTT)"
if strings.EqualFold(cfg.CWKeyLine, "rts") {
lineDesc = "RTS (CW) / DTR (PTT)"
}
applog.Printf("winkeyer: serial CW keyer on %s — %s, PTT=%v (EscapeCommFunction line ctl)", cfg.Port, lineDesc, cfg.UsePTT)
m.emit()
return nil
}
// configCommands is the init sequence for a config — separated from the sending
// so the BYTES can be tested. The command numbers are the contract with the
// hardware, and a wrong one produces a keyer that misbehaves in a way no amount
// of reading the UI explains.
func configCommands(c Config) [][]byte {
cmds := [][]byte{
{0x0E, modeRegister(c)}, // set mode register (paddle mode, swap, autospace…)
{0x02, byte(c.WPM)}, // set speed (WPM)
{0x03, byte(c.Weight)}, // set weighting
{0x04, byte(c.LeadInMs / 10), byte(c.TailMs / 10)}, // PTT lead-in / tail (10 ms units)
// Dit/dah ratio is 0x17. It was being sent as 0x11, which on a WinKeyer 2
// is SET KEY COMPENSATION — in milliseconds. So a neutral ratio of 50 was
// read as 50 ms of extra key-down on every element: at 25 wpm a dit is
// 48 ms, so each element more than doubled and ran into the next. That is
// the reported "it sends one element then stalls", and it was in the trace
// as "TX 11 32".
{0x17, byte(c.Ratio)},
// And clear the compensation explicitly. A keyer left at 50 ms by the
// previous version — or by another program — keeps it in EEPROM, so
// merely stopping the wrong command would not fix an affected keyer.
{0x11, 0x00},
}
// Sidetone: <0x01 n>. Bit6 enables, low nibble selects the pitch divisor.
cmds = append(cmds, []byte{0x01, sidetoneCode(c.Sidetone)})
if c.Farnsworth > 0 {
cmds = append(cmds, []byte{0x0D, byte(c.Farnsworth)}) // Farnsworth WPM
}
return cmds
}
// applyConfig pushes the keying parameters to the device.
func (m *Manager) applyConfig(c Config) error {
for _, cmd := range configCommands(c) {
if err := m.write(cmd); err != nil {
return err
}
}
return nil
}
// modeRegister builds the WinKey mode-register byte (command 0x0E).
//
// bits 1..0 : paddle mode (00 Iambic-B, 01 Iambic-A, 10 Ultimatic, 11 Bug)
// bit 3 : paddle swap
// bit 0/... : (autospace is bit 0 of a separate group on some firmwares)
//
// We keep to the widely-compatible WK2 layout.
func modeRegister(c Config) byte {
var b byte
switch c.Mode {
case ModeIambicB:
b |= 0x00
case ModeIambicA:
b |= 0x10
case ModeUltimatic:
b |= 0x20
case ModeBug:
b |= 0x30
}
if c.Swap {
b |= 0x08 // bit3 paddle swap
}
if c.AutoSpace {
b |= 0x02 // bit1 autospace
}
if c.SerialEcho {
b |= 0x04 // bit2 serial echoback — device echoes keyed chars to host
}
return b
}
// sidetoneCode maps a target Hz to the WinKey sidetone control byte. 0 = off.
func sidetoneCode(hz int) byte {
if hz <= 0 {
return 0x00 // sidetone off
}
// WK sidetone = 4000 / n Hz, n = 1..10. Pick the nearest n, enable bit6.
best, bestErr := 1, 1<<30
for n := 1; n <= 10; n++ {
f := 4000 / n
e := f - hz
if e < 0 {
e = -e
}
if e < bestErr {
bestErr, best = e, n
}
}
return 0x80 | byte(best) // bit7 paddle-only sidetone on; low nibble = divisor
}
// SetSpeed changes the WPM live (command 0x02).
func (m *Manager) SetSpeed(wpm int) error {
if wpm < 5 {
wpm = 5
}
if wpm > 99 {
wpm = 99
}
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()
m.cfg.WPM = wpm
m.status.WPM = wpm
m.mu.Unlock()
m.emit()
return nil
}
// allowedCW is the set of characters WinKey can key (everything else dropped).
const allowedCW = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .,?/=+-:();\"'@"
// Send keys the given text as Morse. The text is upper-cased and filtered to
// keyable characters. Non-keyable input is silently dropped.
func (m *Manager) Send(text string) error {
var b strings.Builder
for _, r := range strings.ToUpper(text) {
if strings.ContainsRune(allowedCW, r) {
b.WriteRune(r)
}
}
out := b.String()
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. 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})
}
func (m *Manager) write(b []byte) error {
m.mu.Lock()
p := m.port
m.mu.Unlock()
if p == nil {
return fmt.Errorf("winkeyer: not connected")
}
traceTX(b)
_, err := p.Write(b)
return err
}
// ── Protocol trace ─────────────────────────────────────────────────────
//
// The WinKeyer command set differs between WK1, WK2 and WK3, and the failures
// it produces are silent: the keyer accepts a byte meant for another command
// and keys something odd. Guessing at that from a description ("it sends one
// element then pauses ten seconds") is how a fix for one firmware breaks the
// others, so the wire is made visible instead.
//
// Off by default — 1200 baud is slow but a long message would still fill the
// log. Enabled per session from the CW settings panel.
var traceOn atomic.Bool
// SetTrace turns the byte-level protocol trace on or off.
func SetTrace(on bool) {
traceOn.Store(on)
if on {
applog.Printf("winkeyer: protocol trace ON — every byte to and from the keyer is logged")
}
}
func hexBytes(b []byte) string {
var sb strings.Builder
for i, c := range b {
if i > 0 {
sb.WriteByte(' ')
}
fmt.Fprintf(&sb, "%02X", c)
if c >= 0x20 && c < 0x7F {
fmt.Fprintf(&sb, "(%c)", c)
}
}
return sb.String()
}
func traceTX(b []byte) {
if !traceOn.Load() || len(b) == 0 {
return
}
applog.Printf("winkeyer: TX %s %s", hexBytes(b), cmdName(b))
}
func traceRX(b []byte) {
if !traceOn.Load() || len(b) == 0 {
return
}
applog.Printf("winkeyer: RX %s", hexBytes(b))
}
// cmdName names the command a TX frame carries, so the trace can be read
// without the datasheet open beside it.
func cmdName(b []byte) string {
if len(b) == 0 || b[0] >= 0x20 {
return "(text)"
}
switch b[0] {
case 0x00:
return "admin"
case 0x01:
return "sidetone"
case 0x02:
return "set wpm"
case 0x03:
return "set weight"
case 0x04:
return "ptt lead/tail"
case 0x05:
return "setup speed pot"
case 0x06:
return "pause"
case 0x0A:
return "clear buffer"
case 0x0D:
return "farnsworth wpm"
case 0x17:
return "set dit/dah ratio"
case 0x0E:
return "set mode register"
case 0x11:
return "set key compensation (ms)"
case 0x15:
return "request status"
default:
return fmt.Sprintf("cmd 0x%02X", b[0])
}
}
// firmwareFamily names the keyer generation from the byte returned by Host
// Open. The version is what decides which command set applies, so it is spelled
// out in the log rather than left as a bare number.
func firmwareFamily(ver int) string {
switch {
case ver == 0:
return "no reply — the keyer did not answer Host Open"
case ver < 20:
return fmt.Sprintf("WK1 (v%d)", ver)
case ver < 30:
return fmt.Sprintf("WK2 (v%d)", ver)
default:
return fmt.Sprintf("WK3 (v%d)", ver)
}
}
// Disconnect sends Host Close and releases the port.
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()
}
if stop != nil {
close(stop)
}
if done != nil {
<-done
}
if connected {
applog.Printf("winkeyer: disconnected")
m.emit()
}
}
// readLoop drains device→host status bytes. WK status frames have bit7 set
// (0xC0 + flags); 0x800xBF carry the speed-pot value; printable bytes are
// the echo of characters being sent. We track busy/idle and the speed pot.
func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) {
defer close(done)
// A panic here (e.g. in a status/echo callback) would otherwise take down
// the whole app — which showed up as a crash when logging a CW QSO while
// the keyer was still streaming echo bytes. Recover, log, end the loop.
defer func() {
if r := recover(); r != nil {
applog.Printf("winkeyer: readLoop panic recovered: %v\n%s", r, debug.Stack())
}
}()
buf := make([]byte, 64)
for {
select {
case <-stop:
return
default:
}
n, err := p.Read(buf)
if err != nil {
// Timeout is normal (no data); a real error ends the loop.
if isTimeout(err) {
continue
}
return
}
traceRX(buf[:n])
for i := 0; i < n; i++ {
b := buf[i]
switch {
case b&0xC0 == 0xC0: // status byte
busy := b&0x04 != 0 // bit2 = busy (sending)
m.mu.Lock()
changed := m.status.Busy != busy
m.status.Busy = busy
m.mu.Unlock()
if changed {
m.emit()
}
case b&0xC0 == 0x80: // speed-pot value: 0x80 | (wpm-min)
// Reported relative to the configured pot range; surfaced as-is.
default:
// Echo of a keyed character (serial echo). Surface printable
// ones so the UI can show the text as it's transmitted.
if b >= 0x20 && b < 0x7F && m.onEcho != nil {
m.onEcho(string(rune(b)))
}
}
}
}
}
func isTimeout(err error) bool {
type timeout interface{ Timeout() bool }
if t, ok := err.(timeout); ok {
return t.Timeout()
}
return strings.Contains(strings.ToLower(err.Error()), "timeout")
}