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.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user