refactor(winkeyer): learn the slow boot instead of naming the keyer

The K3NG entry added an hour ago is gone. It named one clone among many —
WKmini, home-built Arduinos, unbranded boxes — for hardware that speaks
exactly the same protocol, and it was the only line in the engine list that
picked a boot delay rather than a protocol. An operator with an unlabelled
clone would have had to guess.

The delay is now learnt per port. The first connect finds out by failing the
quick attempt and succeeding on the slow one; that fact is written to a
global setting keyed by the port, and every connect afterwards goes straight
to the slow attempt. Global rather than per profile on purpose: which keyer
is plugged into COM3 belongs to the computer, and switching profiles for a
different rig does not change the keyer on the desk.

Two tests hold the contract from both sides — a slow keyer must be reported
as slow, and a keyer that answers at once must not be, or every K1EL connect
would inherit seconds it never needed.
This commit is contained in:
2026-08-14 13:05:13 +02:00
parent aca4dc5678
commit 3ce930e9cc
7 changed files with 94 additions and 30 deletions
+7 -6
View File
@@ -65,9 +65,10 @@ var errNoKeyer = errors.New("no WinKeyer answered on this port — check the cab
// by whoever talked to it last (the first attempt's nulls clear that), and an
// Arduino-based keyer still rebooting from the DTR edge (the second attempt
// waits long enough for it).
// slowBoot skips straight to the long wait: set when the operator has told us
// the keyer is a K3NG, which reboots on every connect.
func hostOpen(p serial.Port, slowBoot bool) (int, error) {
// slowBoot skips straight to the long wait, set when this port has already been
// seen to need it. The second return value says whether the long wait is what
// worked, so the caller can remember it and open quickly next time.
func hostOpen(p serial.Port, slowBoot bool) (ver int, needsSlowBoot bool, err error) {
var lastErr error
for attempt := 1; attempt <= handshakeTry; attempt++ {
wait := bootDelay
@@ -77,16 +78,16 @@ func hostOpen(p serial.Port, slowBoot bool) (int, error) {
ver, err := hostOpenOnce(p, wait)
if err == nil {
if attempt > 1 {
applog.Printf("winkeyer: answered on attempt %d — the keyer needed %s to boot (a K3NG or other Arduino keyer with auto-reset enabled)", attempt, wait)
applog.Printf("winkeyer: answered on attempt %d — this keyer needs %s to boot (a K3NG or another Arduino keyer with auto-reset on); remembering that for this port", attempt, wait)
}
return ver, nil
return ver, wait == resetDelay, nil
}
lastErr = err
if attempt < handshakeTry {
applog.Printf("winkeyer: handshake attempt %d failed (%v) — retrying after %s in case the keyer is rebooting", attempt, err, resetDelay)
}
}
return 0, lastErr
return 0, false, lastErr
}
func hostOpenOnce(p serial.Port, boot time.Duration) (int, error) {
+23 -8
View File
@@ -95,7 +95,7 @@ func (f *fakeKeyer) SetMode(*serial.Mode) error { return nil }
// left mid-command simply absorbed.
func TestHostOpenFollowsK1ELSequence(t *testing.T) {
f := &fakeKeyer{version: 23}
ver, err := hostOpen(f, false)
ver, _, err := hostOpen(f, false)
if err != nil {
t.Fatalf("hostOpen: %v", err)
}
@@ -123,7 +123,7 @@ func TestHostOpenFollowsK1ELSequence(t *testing.T) {
// having to unplug anything.
func TestHostOpenRecoversAConfusedParser(t *testing.T) {
f := &fakeKeyer{version: 30, needsResync: true}
ver, err := hostOpen(f, false)
ver, _, err := hostOpen(f, false)
if err != nil {
t.Fatalf("hostOpen: %v", err)
}
@@ -137,7 +137,7 @@ func TestHostOpenRecoversAConfusedParser(t *testing.T) {
// commands and a keyer that never made a sound.
func TestHostOpenFailsWhenNothingAnswers(t *testing.T) {
f := &fakeKeyer{deaf: true}
if _, err := hostOpen(f, false); !errors.Is(err, errNoKeyer) {
if _, _, err := hostOpen(f, false); !errors.Is(err, errNoKeyer) {
t.Fatalf("want errNoKeyer, got %v", err)
}
}
@@ -146,7 +146,7 @@ func TestHostOpenFailsWhenNothingAnswers(t *testing.T) {
// reported as "no keyer".
func TestHostOpenReportsMissingVersion(t *testing.T) {
f := &fakeKeyer{mute: true}
_, err := hostOpen(f, false)
_, _, err := hostOpen(f, false)
if err == nil {
t.Fatal("want an error")
}
@@ -176,21 +176,36 @@ func (s *slowKeyer) Write(p []byte) (int, error) {
func TestHostOpenWaitsOutAnArduinoReboot(t *testing.T) {
f := &slowKeyer{ready: time.Now().Add(1500 * time.Millisecond)}
f.version = 23
ver, err := hostOpen(f, false)
ver, slow, err := hostOpen(f, false)
if err != nil {
t.Fatalf("hostOpen: %v", err)
}
if ver != 23 {
t.Errorf("version = %d, want 23", ver)
}
// This second value is what gets remembered for the port, and it is the
// whole reason the operator is never asked what kind of keyer they own.
// Lose it and every later connect pays the same doomed quick attempt.
if !slow {
t.Error("the long wait is what worked, but it was not reported as needed")
}
}
// Telling OpsLog the keyer is a K3NG must skip the doomed fast attempt, so the
// first try already allows for the reboot.
// A keyer that answers straight away must NOT be remembered as slow — that
// would add seconds to every connect for a K1EL that never needed them.
func TestHostOpenDoesNotMarkAFastKeyerSlow(t *testing.T) {
f := &fakeKeyer{version: 23}
if _, slow, err := hostOpen(f, false); err != nil || slow {
t.Fatalf("hostOpen = slow %v, err %v — want a fast keyer left alone", slow, err)
}
}
// A port already known to hold a slow keyer skips the doomed fast attempt, so
// the second connect is as quick as a K1EL's.
func TestHostOpenSlowBootSucceedsFirstTry(t *testing.T) {
f := &slowKeyer{ready: time.Now().Add(1500 * time.Millisecond)}
f.version = 23
if _, err := hostOpen(f, true); err != nil {
if _, _, err := hostOpen(f, true); err != nil {
t.Fatalf("hostOpen: %v", err)
}
// One attempt: exactly one handshake on the wire, not two.
+20 -5
View File
@@ -51,10 +51,15 @@ type Config struct {
UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output)
SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host
// SlowBoot skips the quick opening attempt and waits for a keyer that reboots
// when the port opens — a K3NG or any other Arduino-based WinKeyer with its
// auto-reset still enabled. Not an operator setting: it is remembered per port
// the first time a keyer only answers the long attempt, so the second connect
// is as quick as a K1EL's and nobody has to know what is inside the box.
SlowBoot bool `json:"-"`
// Type selects the keyer engine on this serial port:
// "" / "k1el" → a K1EL WinKeyer chip (the default, everything above applies)
// "k3ng" → a K3NG keyer: an Arduino running the same protocol. Identical
// once open; it just needs time to reboot when the port opens.
// "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
@@ -110,12 +115,20 @@ type Manager struct {
onStatus func(Status)
onEcho func(string) // chars the device echoes back as it keys them
// onSlowBoot fires when a keyer answered only after the long wait, so the
// caller can persist that for this port. See Config.SlowBoot.
onSlowBoot func(port string)
}
func NewManager(onStatus func(Status), onEcho func(string)) *Manager {
return &Manager{onStatus: onStatus, onEcho: onEcho}
}
// OnSlowBoot registers the callback that persists "this port needs the long
// opening wait". Optional: without it a slow keyer still connects, it just
// pays the failed quick attempt on every connect.
func (m *Manager) OnSlowBoot(fn func(port string)) { m.onSlowBoot = fn }
// ListPorts returns the available serial port names (COM3, COM6, …).
func ListPorts() ([]string, error) {
ports, err := serial.GetPortsList()
@@ -166,13 +179,15 @@ func (m *Manager) Connect(cfg Config) error {
return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
}
// A K3NG keyer reboots when the port opens (DTR is its reset line), so it is
// given the long wait from the start rather than being failed once first.
ver, err := hostOpen(p, cfg.Type == "k3ng")
ver, slowBoot, err := hostOpen(p, cfg.SlowBoot)
if err != nil {
_ = p.Close()
return fmt.Errorf("winkeyer: %s: %w", cfg.Port, err)
}
// Tell the caller to remember it, so this port opens quickly next time.
if slowBoot && !cfg.SlowBoot && m.onSlowBoot != nil {
m.onSlowBoot(cfg.Port)
}
_ = p.SetReadTimeout(200 * time.Millisecond)
m.mu.Lock()