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.
160 lines
6.1 KiB
Go
160 lines
6.1 KiB
Go
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)
|
||
|
||
// 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
|
||
}
|
||
}
|
||
}
|