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
+7 -2
View File
@@ -289,7 +289,7 @@ const (
keyWKUsePTT = "winkeyer.use_ptt" keyWKUsePTT = "winkeyer.use_ptt"
keyWKSerialEcho = "winkeyer.serial_echo" keyWKSerialEcho = "winkeyer.serial_echo"
keyWKMacros = "winkeyer.macros" // JSON array of {label,text} keyWKMacros = "winkeyer.macros" // JSON array of {label,text}
keyWKEngine = "winkeyer.engine" // "winkeyer" | "serial" | "icom" | "flex" | "tci" keyWKEngine = "winkeyer.engine" // "winkeyer" | "k3ng" | "serial" | "icom" | "flex" | "tci"
keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign
keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed
keyWKEsm = "winkeyer.esm" // Enter-Sends-Message (N1MM-style CW flow) keyWKEsm = "winkeyer.esm" // Enter-Sends-Message (N1MM-style CW flow)
@@ -16689,8 +16689,13 @@ func (a *App) WinkeyerConnect() error {
cfg := s.Config cfg := s.Config
// The "serial" engine keys CW on the port's DTR/RTS lines (SCU-17 style) // The "serial" engine keys CW on the port's DTR/RTS lines (SCU-17 style)
// instead of talking the K1EL WinKeyer protocol — flag it for the manager. // instead of talking the K1EL WinKeyer protocol — flag it for the manager.
if s.Engine == "serial" { // "k3ng" speaks the same protocol as a K1EL and differs only in how long it
// takes to answer after the port opens; the manager needs to know which.
switch s.Engine {
case "serial":
cfg.Type = "serial" cfg.Type = "serial"
case "k3ng":
cfg.Type = "k3ng"
} }
return a.winkeyer.Connect(cfg) return a.winkeyer.Connect(cfg)
} }
+2 -2
View File
@@ -9,7 +9,7 @@
"Appearance: new Sahara theme, warm sand tones for long sessions in daylight.", "Appearance: new Sahara theme, warm sand tones for long sessions in daylight.",
"US counties: Connecticut returned the census planning regions, and San Francisco, Doña Ana, Baltimore city and several Alaska boroughs matched nothing at all. The cluster now shows the same county as the Info panel. Re-download the county database.", "US counties: Connecticut returned the census planning regions, and San Francisco, Doña Ana, Baltimore city and several Alaska boroughs matched nothing at all. The cluster now shows the same county as the Info panel. Re-download the county database.",
"QSL: the manager (QSL_VIA) and the sending method (QSL_SENT_VIA / QSL_RCVD_VIA) are separate fields at last, as ADIF defines them. Imports keep both, exports write both, and a log where they were mixed is offered a correction at startup.", "QSL: the manager (QSL_VIA) and the sending method (QSL_SENT_VIA / QSL_RCVD_VIA) are separate fields at last, as ADIF defines them. Imports keep both, exports write both, and a log where they were mixed is offered a correction at startup.",
"WinKeyer: the opening handshake follows K1ELs own sequence (RTS off, parser resync, echo test), which wakes keyers that stayed silent and a port where nothing answers now says so instead of showing “connected”." "WinKeyer: the opening handshake follows K1ELs own sequence (RTS off, parser resync, echo test), which wakes keyers that stayed silent, and a port where nothing answers now says so instead of showing “connected”. K3NG keyers have their own entry in the list — they reboot when the port opens and need longer to answer."
], ],
"fr": [ "fr": [
"Amplificateurs : cochez ceux qui partagent un combineur et ON, OFF et OPERATE agissent sur tous à la fois. Chacun garde ses propres mesures.", "Amplificateurs : cochez ceux qui partagent un combineur et ON, OFF et OPERATE agissent sur tous à la fois. Chacun garde ses propres mesures.",
@@ -18,7 +18,7 @@
"Apparence : nouveau thème Sahara, tons sable chauds pour les longues sessions en plein jour.", "Apparence : nouveau thème Sahara, tons sable chauds pour les longues sessions en plein jour.",
"Comtés US : le Connecticut renvoyait les planning regions du recensement, et San Francisco, Doña Ana, Baltimore city et plusieurs districts dAlaska ne correspondaient à rien. Le cluster affiche maintenant le même comté que le panneau Info. Rechargez la base des comtés.", "Comtés US : le Connecticut renvoyait les planning regions du recensement, et San Francisco, Doña Ana, Baltimore city et plusieurs districts dAlaska ne correspondaient à rien. Le cluster affiche maintenant le même comté que le panneau Info. Rechargez la base des comtés.",
"QSL : le manager (QSL_VIA) et le mode denvoi (QSL_SENT_VIA / QSL_RCVD_VIA) sont enfin deux champs distincts, comme le veut lADIF. Les imports gardent les deux, les exports les écrivent, et un log où ils étaient mélangés se voit proposer une correction au démarrage.", "QSL : le manager (QSL_VIA) et le mode denvoi (QSL_SENT_VIA / QSL_RCVD_VIA) sont enfin deux champs distincts, comme le veut lADIF. Les imports gardent les deux, les exports les écrivent, et un log où ils étaient mélangés se voit proposer une correction au démarrage.",
"WinKeyer : la séquence douverture suit celle de K1EL (RTS bas, resynchronisation, test d’écho), ce qui réveille les manipulateurs muets et un port où rien ne répond le dit au lieu dafficher « connecté »." "WinKeyer : la séquence douverture suit celle de K1EL (RTS bas, resynchronisation, test d’écho), ce qui réveille les manipulateurs muets, et un port où rien ne répond le dit au lieu dafficher « connecté ». Les K3NG ont leur propre entrée dans la liste : ils redémarrent à louverture du port et mettent plus longtemps à répondre."
] ]
}, },
{ {
@@ -3884,6 +3884,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="winkeyer">{t('wk.engWinkeyer')}</SelectItem> <SelectItem value="winkeyer">{t('wk.engWinkeyer')}</SelectItem>
<SelectItem value="k3ng">{t('wk.engK3ng')}</SelectItem>
<SelectItem value="serial">{t('wk.engSerial')}</SelectItem> <SelectItem value="serial">{t('wk.engSerial')}</SelectItem>
<SelectItem value="icom">{t('wk.engIcom')}</SelectItem> <SelectItem value="icom">{t('wk.engIcom')}</SelectItem>
<SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem> <SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
+2 -2
View File
@@ -165,7 +165,7 @@ const en: Dict = {
'wk.escClears': 'ESC clears the callsign too (otherwise ESC only stops transmission)', 'wk.escClears': 'ESC clears the callsign too (otherwise ESC only stops transmission)',
'wk.esm': 'ESM — Enter Sends Message (CW)', 'wk.esm': 'ESM — Enter Sends Message (CW)',
'wk.esmHint': 'In CW with the keyer on, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs if the macro contains <LOGQSO>.', 'wk.esmHint': 'In CW with the keyer on, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs if the macro contains <LOGQSO>.',
'wk.engWinkeyer': 'WinKeyer (K1EL, serial)', 'wk.engSerial': 'Serial port (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (rig keyer)', 'wk.engYaesu': 'Yaesu (rig keyer)', 'wk.yaesuHint': 'Keying goes over the CAT link already configured in Settings → CAT — no second COM port. For the FTDX101 / FT-991A / FT-710 family. An FTDX10 does NOT accept it (DAKY included): on that one use the "Serial port (DTR=CW / RTS=PTT)" keyer on its other COM port (the standard one) with PC KEYING on DTR — confirmed working.', 'wk.catWarnYaesu': 'The Yaesu keyer needs the Yaesu CAT backend — CAT is currently {backend}.', 'wk.engKenwood': 'Kenwood / Elecraft (rig keyer)', 'wk.kenwoodHint': 'Keying goes over the CAT link already configured in Settings → CAT (the KY command) — no second COM port. Ideal for an Elecraft K3/K4 whose single USB port is the CAT port.', 'wk.catWarnKenwood': 'The Kenwood/Elecraft keyer needs the Kenwood/Elecraft CAT backend — CAT is currently {backend}.', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (coming soon)', 'wk.engK3ng': 'K3NG (WinKeyer emulation)', 'wk.engWinkeyer': 'WinKeyer (K1EL, serial)', 'wk.engSerial': 'Serial port (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (rig keyer)', 'wk.engYaesu': 'Yaesu (rig keyer)', 'wk.yaesuHint': 'Keying goes over the CAT link already configured in Settings → CAT — no second COM port. For the FTDX101 / FT-991A / FT-710 family. An FTDX10 does NOT accept it (DAKY included): on that one use the "Serial port (DTR=CW / RTS=PTT)" keyer on its other COM port (the standard one) with PC KEYING on DTR — confirmed working.', 'wk.catWarnYaesu': 'The Yaesu keyer needs the Yaesu CAT backend — CAT is currently {backend}.', 'wk.engKenwood': 'Kenwood / Elecraft (rig keyer)', 'wk.kenwoodHint': 'Keying goes over the CAT link already configured in Settings → CAT (the KY command) — no second COM port. Ideal for an Elecraft K3/K4 whose single USB port is the CAT port.', 'wk.catWarnKenwood': 'The Kenwood/Elecraft keyer needs the Kenwood/Elecraft CAT backend — CAT is currently {backend}.', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (coming soon)',
'wk.icomNote': "Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode… are configured on the radio; only the speed is set from here (the rig's KEY SPEED).", 'wk.icomNote': "Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode… are configured on the radio; only the speed is set from here (the rig's KEY SPEED).",
'wk.flexNote': "FlexRadio keys CW through the radio's CWX keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).", 'wk.flexNote': "FlexRadio keys CW through the radio's CWX keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).",
'wk.catWarnIcom': 'Your CAT backend is set to {backend}. Icom CI-V CW needs the CAT backend set to Icom and connected — change it under Settings → CAT interface, otherwise sending CW will fail.', 'wk.catWarnIcom': 'Your CAT backend is set to {backend}. Icom CI-V CW needs the CAT backend set to Icom and connected — change it under Settings → CAT interface, otherwise sending CW will fail.',
@@ -593,7 +593,7 @@ const fr: Dict = {
'wk.escClears': "ÉCHAP efface aussi l'indicatif (sinon ÉCHAP arrête seulement la transmission)", 'wk.escClears': "ÉCHAP efface aussi l'indicatif (sinon ÉCHAP arrête seulement la transmission)",
'wk.esm': 'ESM — Entrée envoie le message (CW)', 'wk.esm': 'ESM — Entrée envoie le message (CW)',
'wk.esmHint': "En CW avec le keyer actif, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue si le macro contient <LOGQSO>.", 'wk.esmHint': "En CW avec le keyer actif, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue si le macro contient <LOGQSO>.",
'wk.engWinkeyer': 'WinKeyer (K1EL, série)', 'wk.engSerial': 'Port série (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (keyer de la radio)', 'wk.engYaesu': 'Yaesu (keyer de la radio)', 'wk.yaesuHint': "La manipulation passe par la liaison CAT déjà configurée dans Réglages → CAT — sans second port COM. Pour la famille FTDX101 / FT-991A / FT-710. Un FTDX10 ne l'accepte PAS (DAKY compris) : sur celui-ci, utilisez le keyer « Port série (DTR=CW / RTS=PTT) » sur son autre port COM (le standard) avec PC KEYING sur DTR — confirmé fonctionnel.", 'wk.catWarnYaesu': 'Le keyer Yaesu nécessite le backend CAT Yaesu — le CAT est actuellement : {backend}.', 'wk.engKenwood': 'Kenwood / Elecraft (keyer de la radio)', 'wk.kenwoodHint': "La manipulation passe par la liaison CAT déjà configurée dans Réglages → CAT (la commande KY) — sans second port COM. Idéal pour un Elecraft K3/K4 dont l'unique port USB est le port CAT.", 'wk.catWarnKenwood': 'Le keyer Kenwood/Elecraft nécessite le backend CAT Kenwood/Elecraft — le CAT est actuellement : {backend}.', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (bientôt)', 'wk.engK3ng': 'K3NG (émulation WinKeyer)', 'wk.engWinkeyer': 'WinKeyer (K1EL, série)', 'wk.engSerial': 'Port série (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (keyer de la radio)', 'wk.engYaesu': 'Yaesu (keyer de la radio)', 'wk.yaesuHint': "La manipulation passe par la liaison CAT déjà configurée dans Réglages → CAT — sans second port COM. Pour la famille FTDX101 / FT-991A / FT-710. Un FTDX10 ne l'accepte PAS (DAKY compris) : sur celui-ci, utilisez le keyer « Port série (DTR=CW / RTS=PTT) » sur son autre port COM (le standard) avec PC KEYING sur DTR — confirmé fonctionnel.", 'wk.catWarnYaesu': 'Le keyer Yaesu nécessite le backend CAT Yaesu — le CAT est actuellement : {backend}.', 'wk.engKenwood': 'Kenwood / Elecraft (keyer de la radio)', 'wk.kenwoodHint': "La manipulation passe par la liaison CAT déjà configurée dans Réglages → CAT (la commande KY) — sans second port COM. Idéal pour un Elecraft K3/K4 dont l'unique port USB est le port CAT.", 'wk.catWarnKenwood': 'Le keyer Kenwood/Elecraft nécessite le backend CAT Kenwood/Elecraft — le CAT est actuellement : {backend}.', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (bientôt)',
'wk.icomNote': "L'Icom CI-V manipule la CW via le keyer interne de la radio sur la connexion CAT existante (commande 0x17) — il réutilise le port COM CAT défini dans Réglages → CAT, rien d'autre à câbler ici. Mets la radio en mode CW. Poids, ratio, sidetone, mode paddle… se règlent sur la radio ; seule la vitesse est définie ici (KEY SPEED de la radio).", 'wk.icomNote': "L'Icom CI-V manipule la CW via le keyer interne de la radio sur la connexion CAT existante (commande 0x17) — il réutilise le port COM CAT défini dans Réglages → CAT, rien d'autre à câbler ici. Mets la radio en mode CW. Poids, ratio, sidetone, mode paddle… se règlent sur la radio ; seule la vitesse est définie ici (KEY SPEED de la radio).",
'wk.flexNote': "FlexRadio manipule la CW via le keyer CWX de la radio sur la connexion SmartSDR CAT existante — pas besoin de WinKeyer ni de SmartCAT. Il réutilise la connexion définie dans Réglages → CAT, rien d'autre à câbler ici. Mets une slice en mode CW. Seule la vitesse est définie ici ; poids, sidetone et break-in se règlent sur la radio (le break-in doit être activé pour que la CW parte vraiment).", 'wk.flexNote': "FlexRadio manipule la CW via le keyer CWX de la radio sur la connexion SmartSDR CAT existante — pas besoin de WinKeyer ni de SmartCAT. Il réutilise la connexion définie dans Réglages → CAT, rien d'autre à câbler ici. Mets une slice en mode CW. Seule la vitesse est définie ici ; poids, sidetone et break-in se règlent sur la radio (le break-in doit être activé pour que la CW parte vraiment).",
'wk.catWarnIcom': "Ton backend CAT est réglé sur {backend}. La CW Icom CI-V nécessite le backend CAT réglé sur Icom et connecté — change-le dans Réglages → Interface CAT, sinon l'envoi CW échouera.", 'wk.catWarnIcom': "Ton backend CAT est réglé sur {backend}. La CW Icom CI-V nécessite le backend CAT réglé sur Icom et connecté — change-le dans Réglages → Interface CAT, sinon l'envoi CW échouera.",
+34 -8
View File
@@ -38,6 +38,21 @@ const (
bootDelay = 400 * time.Millisecond bootDelay = 400 * time.Millisecond
echoTimeout = 2 * time.Second // K1EL: "if a WK doesn't respond within 2 seconds abort" echoTimeout = 2 * time.Second // K1EL: "if a WK doesn't respond within 2 seconds abort"
openTimeout = 2 * time.Second 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 handshakeTry = 2
) )
@@ -45,27 +60,38 @@ const (
var errNoKeyer = errors.New("no WinKeyer answered on this port — check the cable, the port, and that no other program holds the keyer") 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 // 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 // byte. It is tried twice, and the two attempts cover the two ways a keyer that
// common case, the nulls of the first attempt clear it, and the second then // is plugged in and working can miss being spoken to: a parser left mid-command
// succeeds. // by whoever talked to it last (the first attempt's nulls clear that), and an
func hostOpen(p serial.Port) (int, error) { // 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 the operator has told us
// the keyer is a K3NG, which reboots on every connect.
func hostOpen(p serial.Port, slowBoot bool) (int, error) {
var lastErr error var lastErr error
for attempt := 1; attempt <= handshakeTry; attempt++ { for attempt := 1; attempt <= handshakeTry; attempt++ {
ver, err := hostOpenOnce(p) wait := bootDelay
if attempt > 1 || slowBoot {
wait = resetDelay
}
ver, err := hostOpenOnce(p, wait)
if err == nil { if err == nil {
if attempt > 1 {
applog.Printf("winkeyer: answered on attempt %d — the keyer needed %s to boot (a K3NG or other Arduino keyer with auto-reset enabled)", attempt, wait)
}
return ver, nil return ver, nil
} }
lastErr = err lastErr = err
if attempt < handshakeTry { if attempt < handshakeTry {
applog.Printf("winkeyer: handshake attempt %d failed (%v) — retrying", attempt, err) applog.Printf("winkeyer: handshake attempt %d failed (%v) — retrying after %s in case the keyer is rebooting", attempt, err, resetDelay)
} }
} }
return 0, lastErr return 0, lastErr
} }
func hostOpenOnce(p serial.Port) (int, error) { func hostOpenOnce(p serial.Port, boot time.Duration) (int, error) {
// The keyer may still be booting off the DTR line we just raised. // The keyer may still be booting off the DTR line we just raised.
time.Sleep(bootDelay) time.Sleep(boot)
drain(p) drain(p)
// Resync the command parser before asking it anything. // Resync the command parser before asking it anything.
+49 -4
View File
@@ -95,7 +95,7 @@ func (f *fakeKeyer) SetMode(*serial.Mode) error { return nil }
// left mid-command simply absorbed. // left mid-command simply absorbed.
func TestHostOpenFollowsK1ELSequence(t *testing.T) { func TestHostOpenFollowsK1ELSequence(t *testing.T) {
f := &fakeKeyer{version: 23} f := &fakeKeyer{version: 23}
ver, err := hostOpen(f) ver, err := hostOpen(f, false)
if err != nil { if err != nil {
t.Fatalf("hostOpen: %v", err) t.Fatalf("hostOpen: %v", err)
} }
@@ -123,7 +123,7 @@ func TestHostOpenFollowsK1ELSequence(t *testing.T) {
// having to unplug anything. // having to unplug anything.
func TestHostOpenRecoversAConfusedParser(t *testing.T) { func TestHostOpenRecoversAConfusedParser(t *testing.T) {
f := &fakeKeyer{version: 30, needsResync: true} f := &fakeKeyer{version: 30, needsResync: true}
ver, err := hostOpen(f) ver, err := hostOpen(f, false)
if err != nil { if err != nil {
t.Fatalf("hostOpen: %v", err) t.Fatalf("hostOpen: %v", err)
} }
@@ -137,7 +137,7 @@ func TestHostOpenRecoversAConfusedParser(t *testing.T) {
// commands and a keyer that never made a sound. // commands and a keyer that never made a sound.
func TestHostOpenFailsWhenNothingAnswers(t *testing.T) { func TestHostOpenFailsWhenNothingAnswers(t *testing.T) {
f := &fakeKeyer{deaf: true} 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) t.Fatalf("want errNoKeyer, got %v", err)
} }
} }
@@ -146,7 +146,7 @@ func TestHostOpenFailsWhenNothingAnswers(t *testing.T) {
// reported as "no keyer". // reported as "no keyer".
func TestHostOpenReportsMissingVersion(t *testing.T) { func TestHostOpenReportsMissingVersion(t *testing.T) {
f := &fakeKeyer{mute: true} f := &fakeKeyer{mute: true}
_, err := hostOpen(f) _, err := hostOpen(f, false)
if err == nil { if err == nil {
t.Fatal("want an error") 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) 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)
}
}
+5 -1
View File
@@ -53,6 +53,8 @@ type Config struct {
// Type selects the keyer engine on this serial port: // Type selects the keyer engine on this serial port:
// "" / "k1el" → a K1EL WinKeyer chip (the default, everything above applies) // "" / "k1el" → a K1EL WinKeyer chip (the default, everything above applies)
// "k3ng" → a K3NG keyer: an Arduino running the same protocol. Identical
// once open; it just needs time to reboot when the port opens.
// "serial" → the PC bit-bangs Morse on a control line (no WinKeyer chip): // "serial" → the PC bit-bangs Morse on a control line (no WinKeyer chip):
// the "hardware CW keying" a Yaesu SCU-17 / generic interface // the "hardware CW keying" a Yaesu SCU-17 / generic interface
// uses. WPM / Weight / Farnsworth / LeadIn / Tail / UsePTT still // uses. WPM / Weight / Farnsworth / LeadIn / Tail / UsePTT still
@@ -164,7 +166,9 @@ func (m *Manager) Connect(cfg Config) error {
return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err) return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
} }
ver, err := hostOpen(p) // A K3NG keyer reboots when the port opens (DTR is its reset line), so it is
// given the long wait from the start rather than being failed once first.
ver, err := hostOpen(p, cfg.Type == "k3ng")
if err != nil { if err != nil {
_ = p.Close() _ = p.Close()
return fmt.Errorf("winkeyer: %s: %w", cfg.Port, err) return fmt.Errorf("winkeyer: %s: %w", cfg.Port, err)