Grey line — the day/night terminator and its twilight band, off by default, redrawn every minute so a map left open all day is not silently wrong by evening. Its own Leaflet pane below the overlays, non-interactive, so it never hides the path or beam nor swallows a click. The solar maths is pure and was checked against known positions before being wired to anything: both solstices, the March equinox, the subsolar longitude at 12 UTC, and day/night at Paris, Tokyo, New York, Sydney and Reykjavik across seasons and hemispheres. That last set is what caught the real bug: the equation has TWO solutions per meridian and the naive branch put New York in daylight at 02:00 in December — the terminator at +63° where it belongs at −63°. A shaded map that looks entirely plausible and is exactly mirrored. It now anchors on the classical terminator and takes the branch nearest it, which is also what makes the twilight band follow the terminator instead of jumping hemisphere. Separately, from a Xiegu user: the protocol-trace checkbox does not stay ticked. It is React state initialised to false on every mount, so a trace still running came back unticked — the operator ticks it to "switch it on", which switches it OFF, and the log they send contains no trace at all. Worse than a cosmetic bug: it silently defeats the one tool asked for to diagnose their radio. Both boxes (CAT and WinKeyer) now read their real state from the backend.
640 lines
18 KiB
Go
640 lines
18 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
|
||
}
|
||
|
||
// 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); 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")
|
||
}
|
||
|
||
// TraceEnabled reports whether the protocol trace is running.
|
||
//
|
||
// The settings dialog needs it: the checkbox is React state that starts false on
|
||
// every mount, so a trace switched on stayed on in the backend while the box
|
||
// reappeared unticked — the operator ticks it again, which switches it OFF, and
|
||
// the log they then send has no trace in it at all.
|
||
func TraceEnabled() bool { return traceOn.Load() }
|