Statistics — the activity chart ignored the period selector: picking a year up top left it showing the last 7 days, two controls contradicting each other. The buttons were four fixed rolling windows anchored on the newest QSO, not granularities. They now choose the BUCKET SIZE over the selected period, with an Auto default and a step-up when a bucket would be too fine (the backend drops a series past 750 buckets rather than ship 6000 unreadable bars). The hour-of-day histogram covered a single day, too little to read anything from; it moves to its own card with a weekday view, over the whole period. Band map — the CW/data/phone sub-bands were shaded with the STATUS tokens, the same amber that means "new band" on the pills drawn on top of them. A sub-band is an identity, not a state: categorical hues now, validated in both themes (violet failed on the dark steps — 1.9 ΔE from blue for a protan reader) and added to the legend, since identity must never be colour-alone. Frameless window — the OS title bar was a dead 32px band above a window that already has its own. Minimise/maximise/close move into the app header, which carries the drag region and double-click-to-maximise. The drag opt-out is by ROLE in CSS, so a future button in that bar keeps working without anyone remembering the rule. Fixes: - Saving settings froze the window while a device was slow: restarting a link waits for its poll goroutine, which can sit 45 s inside an uninterruptible COM Connect when another program holds the rig. The restart is now off the UI path; a slow one is logged. - Multi-screen: a MAXIMISED window was never repositioned, so it came back on the primary screen every launch. The corner is now recorded even when maximised (it names the monitor) and re-applied while still hidden. - The Callsign field is marked out at rest — operators were typing the call into Name, which sat beside it and looked identical. - QSL dates get a real picker (native, for the OS calendar and locale order); a malformed old value stays in a text box rather than vanishing behind an empty one. Also: a Support OpsLog entry in Help, and a WinKeyer protocol trace. The WK2 report (one element then a ten-second pause, sidetone that will not switch off) is NOT fixed here: the WK1/WK2/WK3 command sets differ and a guessed fix would break the operators it works for today. The trace logs every byte with the command name and spells out the firmware family, which is what will settle it.
612 lines
17 KiB
Go
612 lines
17 KiB
Go
// 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"
|
||
"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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
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 0x0E:
|
||
return "set mode register"
|
||
case 0x11:
|
||
return "0x11 (WK2: key compensation / WK3: see datasheet)"
|
||
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); 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)
|
||
// 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")
|
||
}
|