feat: Winkeyer
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in New Issue
Block a user