Files
OpsLog/internal/winkeyer/hostopen.go
T
rouggy 1b736272c9 fix(winkeyer): perform K1EL's full opening handshake
An operator's log showed the whole fault in its first line: "connected on
COM3 — no reply — the keyer did not answer Host Open", followed by seven
configuration commands and two calls sent as Morse. Nothing was listening.
Reporting a link as up and then writing to it regardless is the part worth
fixing; the handshake is why it was down.

K1EL's Application Interface Guide gives the sequence, and we did one step
of it. Now all of it:

  - DTR on, RTS OFF. K1EL's own init sets DTR_CONTROL_ENABLE with
    RTS_CONTROL_DISABLE, and on a serial WinKeyer those lines ARE the power
    supply — DTR feeds the 3.3 V regulator, RTS provides the negative rail.
    go.bug.st/serial defaults both to true, so we drove RTS high on every
    connect without a line of code saying so.
  - 400 ms after the lines come up, for a WK1 still booting off DTR.
  - Three 0x13 nulls to resync the command parser. A keyer left part-way
    through a command by whoever spoke to it last would absorb Host Open as
    a parameter — the everyday cause of a silent WinKeyer, and one the
    operator can do nothing about from the outside.
  - An echo test (0x00 0x04 0x55) before trusting the port at all. This is
    the step that answers "is there a keyer here", and connecting now fails
    on it, with the byte that came back when something else replied.

The whole handshake is retried once, since the first attempt's nulls are
what clear a confused parser. Tested against a fake port that reproduces
each failure: absent, mid-command, and echoing but versionless.
2026-08-14 12:07:58 +02:00

133 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package winkeyer
import (
"errors"
"fmt"
"time"
"go.bug.st/serial"
"hamlog/internal/applog"
)
// The opening handshake, as K1EL specifies it in the WinKeyer2 Application
// Interface Guide ("WK Init Psuedo Code"). OpsLog used to send Host Open alone
// and carry on whatever came back, which is how an operator ended up with a
// keyer reported as connected, a full set of settings written to it, and not
// one character keyed — the log said "no reply" and then behaved as if there
// had been one.
//
// The steps exist for reasons that are not obvious from the byte values:
//
// 400 ms a WK1 is still powering up off the DTR line when the port opens;
// WK2 and later do not need it, and it costs nothing once.
// 0x13 ×3 null commands. WinKey's parser may be part-way through a command
// left over from whoever spoke to it last — another logger, or us
// before a crash. A command byte expecting parameters would swallow
// Host Open whole. Three nulls flush that state out.
// echo ask the keyer to send one known byte back. This is the only step
// that answers "is there really a WinKeyer on this port", and it is
// the one to fail on: everything after it assumes a listener.
// open 0x00 0x02, and the keyer returns its firmware version.
const (
cmdNull = 0x13
cmdAdmin = 0x00
adminOpen = 0x02
adminEcho = 0x04
echoProbe = 0x55 // K1EL's own choice; any byte works, this one is 0b01010101
bootDelay = 400 * time.Millisecond
echoTimeout = 2 * time.Second // K1EL: "if a WK doesn't respond within 2 seconds abort"
openTimeout = 2 * time.Second
handshakeTry = 2
)
// errNoKeyer is returned when nothing answers the echo probe.
var errNoKeyer = errors.New("no WinKeyer answered on this port — check the cable, the port, and that no other program holds the keyer")
// hostOpen runs the full documented handshake and returns the firmware version
// byte. It is tried twice: a keyer left mid-command by another program is the
// common case, the nulls of the first attempt clear it, and the second then
// succeeds.
func hostOpen(p serial.Port) (int, error) {
var lastErr error
for attempt := 1; attempt <= handshakeTry; attempt++ {
ver, err := hostOpenOnce(p)
if err == nil {
return ver, nil
}
lastErr = err
if attempt < handshakeTry {
applog.Printf("winkeyer: handshake attempt %d failed (%v) — retrying", attempt, err)
}
}
return 0, lastErr
}
func hostOpenOnce(p serial.Port) (int, error) {
// The keyer may still be booting off the DTR line we just raised.
time.Sleep(bootDelay)
drain(p)
// Resync the command parser before asking it anything.
if _, err := p.Write([]byte{cmdNull, cmdNull, cmdNull}); err != nil {
return 0, fmt.Errorf("resync: %w", err)
}
time.Sleep(50 * time.Millisecond)
drain(p)
// Is anything actually there?
if _, err := p.Write([]byte{cmdAdmin, adminEcho, echoProbe}); err != nil {
return 0, fmt.Errorf("echo test: %w", err)
}
b, ok := readByte(p, echoTimeout)
if !ok {
return 0, errNoKeyer
}
if b != echoProbe {
// Something replied, but not what we asked for. Say what came back —
// on a wrong port that byte is the only clue to what is on the other end.
return 0, fmt.Errorf("echo test: expected 0x%02X, got 0x%02X — is this the keyer's port?", echoProbe, b)
}
if _, err := p.Write([]byte{cmdAdmin, adminOpen}); err != nil {
return 0, fmt.Errorf("host open: %w", err)
}
ver, ok := readByte(p, openTimeout)
if !ok {
return 0, errors.New("host open: the keyer echoed but did not return its firmware version")
}
return int(ver), nil
}
// readByte waits up to d for one byte. The serial read timeout is per-call and
// can return 0 bytes without an error, so this loops until the deadline rather
// than trusting a single Read.
func readByte(p serial.Port, d time.Duration) (byte, bool) {
_ = p.SetReadTimeout(200 * time.Millisecond)
deadline := time.Now().Add(d)
buf := make([]byte, 1)
for time.Now().Before(deadline) {
n, err := p.Read(buf)
if n > 0 {
return buf[0], true
}
if err != nil {
return 0, false
}
}
return 0, false
}
// drain throws away anything already waiting — a status byte from a previous
// session, or the tail of a reply we are no longer interested in.
func drain(p serial.Port) {
_ = p.SetReadTimeout(20 * time.Millisecond)
buf := make([]byte, 64)
for i := 0; i < 16; i++ {
n, err := p.Read(buf)
if n == 0 || err != nil {
return
}
}
}