// 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 0x00–0x1F 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" "strings" "sync" "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 } 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{} 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") } 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 (firmware byte %d)", cfg.Port, ver) go m.readLoop(p, stop, done) if err := m.applyConfig(cfg); err != nil { applog.Printf("winkeyer: applyConfig: %v", err) } m.emit() return nil } // applyConfig pushes the keying parameters to the device. func (m *Manager) applyConfig(c Config) error { 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) {0x11, byte(c.Ratio)}, // set dit/dah ratio } // 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 } for _, cmd := range cmds { 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 } 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 } return m.write([]byte(out)) } // Stop aborts the current message and clears the keyer buffer (command 0x0A). func (m *Manager) Stop() error { 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. func (m *Manager) Backspace() error { 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") } _, err := p.Write(b) return err } // Disconnect sends Host Close and releases the port. func (m *Manager) Disconnect() { m.mu.Lock() p := m.port stop, done := m.stopRead, m.doneRead m.port = nil m.stopRead = nil m.doneRead = nil connected := m.status.Connected m.status = Status{Connected: false} m.mu.Unlock() 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); 0x80–0xBF 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) 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 } 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") }