feat(winkeyer): K3NG keyers, and wait out their reboot

The operator's keyer is a K3NG — an Arduino running an emulation of the
WinKeyer protocol — which changes the diagnosis and nearly broke it.

Checked against K3NG's own source before anything else, because yesterday's
handshake now FAILS a connect where it used to press on regardless. It is
safe: k3ng_keyer.ino implements admin echo (0x04) and echoes the byte back,
0x13 is a documented no-op, and OPTION_WINKEY_STRICT_HOST_OPEN — on by
default — ignores every byte except 0x00 before host open, so the resync
nulls are dropped harmlessly and the echo probe still gets through.

The real cause is in K3NG's options file, beside the feature itself:
"disabling Automatic Software Reset is highly recommended", and an option
to "discard errant serial port bytes at startup" for when it is not. On an
Arduino, DTR is wired to reset through a capacitor: opening the port reboots
the board into its bootloader. Ours spoke 400 ms later, to a keyer that was
not running yet.

So the retry now waits 2.5 s, which recovers it without an operator pressing
connect twice, and the engine list gains a K3NG entry that skips the doomed
fast attempt altogether. Everything else is identical to a K1EL — same
settings panel, same protocol.
This commit is contained in:
2026-08-14 12:40:42 +02:00
parent c495dced0f
commit aca4dc5678
7 changed files with 104 additions and 23 deletions
+49 -4
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)
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)
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); !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)
_, err := hostOpen(f, false)
if err == nil {
t.Fatal("want an error")
}
@@ -154,3 +154,48 @@ func TestHostOpenReportsMissingVersion(t *testing.T) {
t.Fatalf("a keyer that echoed was reported as absent: %v", err)
}
}
// slowKeyer answers nothing until it has been "powered up" for d — a K3NG on an
// Arduino, which the DTR edge from opening the port drops into its bootloader.
type slowKeyer struct {
fakeKeyer
ready time.Time
}
func (s *slowKeyer) Write(p []byte) (int, error) {
if time.Now().Before(s.ready) {
return len(p), nil // still in the bootloader — the bytes are lost
}
return s.fakeKeyer.Write(p)
}
// TestHostOpenWaitsOutAnArduinoReboot is the case that started this: a K3NG
// keyer reboots when the port opens, so it misses a handshake sent 400 ms
// later. The retry has to wait long enough, and must not need the operator to
// press connect twice.
func TestHostOpenWaitsOutAnArduinoReboot(t *testing.T) {
f := &slowKeyer{ready: time.Now().Add(1500 * time.Millisecond)}
f.version = 23
ver, err := hostOpen(f, false)
if err != nil {
t.Fatalf("hostOpen: %v", err)
}
if ver != 23 {
t.Errorf("version = %d, want 23", ver)
}
}
// Telling OpsLog the keyer is a K3NG must skip the doomed fast attempt, so the
// first try already allows for the reboot.
func TestHostOpenSlowBootSucceedsFirstTry(t *testing.T) {
f := &slowKeyer{ready: time.Now().Add(1500 * time.Millisecond)}
f.version = 23
if _, err := hostOpen(f, true); err != nil {
t.Fatalf("hostOpen: %v", err)
}
// One attempt: exactly one handshake on the wire, not two.
want := len([]byte{cmdNull, cmdNull, cmdNull, cmdAdmin, adminEcho, echoProbe, cmdAdmin, adminOpen})
if got := len(f.sent()); got != want {
t.Errorf("sent %d bytes, want %d — the fast attempt was not skipped", got, want)
}
}