Files
OpsLog/internal/winkeyer/hostopen.go
T
rouggy 3def789742 fix(winkeyer): send the probe the way a working client sends it
Logger32's WinKeyer debug against the K3NG that will not answer OpsLog is a
capture of the exchange working, on the same keyer and the same port:

    Sent: 13 13 13 00 04 55
    Rcvd: 55            (72 ms)
    Sent: 00 02  Host open
    Rcvd: 23            (WK2 v23)

That is the sequence I already send. The difference is the grouping: Logger32
puts the three nulls and the echo probe in ONE write, and we split them with a
50 ms pause and a buffer purge in between. On a keyer that reboots when the
port opens, that pause is a window for it to come up mid-sequence and swallow
half of it — and there was nothing to wait for, since a null produces no reply.

The handshake bytes are now logged unconditionally, not behind the diagnostic
option. "No WinKeyer answered" cannot be told apart from a wrong port, a wrong
baud rate, a keyer still booting, or another program holding the line. The
bytes can, and it is four lines per connect attempt.
2026-08-15 10:28:11 +02:00

188 lines
7.4 KiB
Go
Raw Permalink 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
// resetDelay is the second attempt's wait, and it is not there for a K1EL.
//
// Plenty of "WinKeyers" are a K3NG keyer — an Arduino running an emulation
// of the same protocol. On an Arduino, DTR is wired to the reset pin through
// a capacitor: raising it when the port opens REBOOTS the board, which then
// sits in its bootloader before the sketch even starts. K3NG's own options
// file says as much ("disabling Automatic Software Reset is highly
// recommended", and an option to "discard errant serial port bytes at
// startup" for when it is not). 400 ms is nowhere near long enough, so the
// keyer misses the whole handshake and looks absent.
//
// Rather than make every operator wait for the slowest possible device, the
// first attempt stays quick and only the retry allows for a reboot.
resetDelay = 2500 * time.Millisecond
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, and the two attempts cover the two ways a keyer that
// is plugged in and working can miss being spoken to: a parser left mid-command
// 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 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
if attempt > 1 || slowBoot {
wait = resetDelay
}
ver, err := hostOpenOnce(p, wait)
if err == nil {
if attempt > 1 {
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, 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, false, lastErr
}
func hostOpenOnce(p serial.Port, boot time.Duration) (int, error) {
// The keyer may still be booting off the DTR line we just raised.
time.Sleep(boot)
drain(p)
// The resync nulls and the echo probe go out as ONE write.
//
// Copied byte for byte from a Logger32 capture against the K3NG keyer that
// would not answer OpsLog: "Sent: 13 13 13 00 04 55 / Rcvd: 55". Same keyer,
// same port, same six bytes — the only difference was that we sent them as
// two writes with a pause and a buffer purge in between, and Logger32 sends
// them as one. On a keyer that reboots when the port opens, that pause is a
// window for it to come up mid-sequence and swallow half of it.
//
// There is nothing to wait for between the two halves anyway: a null produces
// no reply, so the pause was only ever giving the keyer a chance to change
// its mind.
probe := []byte{cmdNull, cmdNull, cmdNull, cmdAdmin, adminEcho, echoProbe}
traceHandshake("TX", probe, 0, false)
if _, err := p.Write(probe); err != nil {
return 0, fmt.Errorf("echo test: %w", err)
}
b, ok := readByte(p, echoTimeout)
traceHandshake("RX", nil, b, ok)
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)
}
open := []byte{cmdAdmin, adminOpen}
traceHandshake("TX", open, 0, false)
if _, err := p.Write(open); err != nil {
return 0, fmt.Errorf("host open: %w", err)
}
ver, ok := readByte(p, openTimeout)
traceHandshake("RX", nil, ver, ok)
if !ok {
return 0, errors.New("host open: the keyer echoed but did not return its firmware version")
}
return int(ver), nil
}
// traceHandshake puts the opening exchange in the log, ALWAYS — unlike the
// running trace beside it, which is behind the diagnostic option.
//
// A failure that says only "no WinKeyer answered" cannot be told apart from a
// wrong port, a wrong baud rate, a keyer still rebooting, or another program
// holding the line. The bytes can. It is four lines per connect, and only when
// the connect is attempted.
func traceHandshake(dir string, b []byte, got byte, ok bool) {
switch {
case dir == "TX":
applog.Printf("winkeyer: handshake TX % 02X", b)
case ok:
applog.Printf("winkeyer: handshake RX %02X", got)
default:
applog.Printf("winkeyer: handshake RX — nothing came back")
}
}
// 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
}
}
}