diff --git a/app.go b/app.go index b0099a2..7ec9890 100644 --- a/app.go +++ b/app.go @@ -289,7 +289,7 @@ const ( keyWKUsePTT = "winkeyer.use_ptt" keyWKSerialEcho = "winkeyer.serial_echo" 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 keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed keyWKEsm = "winkeyer.esm" // Enter-Sends-Message (N1MM-style CW flow) @@ -16689,8 +16689,13 @@ func (a *App) WinkeyerConnect() error { cfg := s.Config // 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. - 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" + case "k3ng": + cfg.Type = "k3ng" } return a.winkeyer.Connect(cfg) } diff --git a/changelog.json b/changelog.json index 140a032..7bcfe4f 100644 --- a/changelog.json +++ b/changelog.json @@ -9,7 +9,7 @@ "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.", "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 K1EL’s 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 K1EL’s 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": [ "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.", "Comtés US : le Connecticut renvoyait les planning regions du recensement, et San Francisco, Doña Ana, Baltimore city et plusieurs districts d’Alaska 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 d’envoi (QSL_SENT_VIA / QSL_RCVD_VIA) sont enfin deux champs distincts, comme le veut l’ADIF. 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 d’ouverture 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 d’afficher « connecté »." + "WinKeyer : la séquence d’ouverture 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 d’afficher « connecté ». Les K3NG ont leur propre entrée dans la liste : ils redémarrent à l’ouverture du port et mettent plus longtemps à répondre." ] }, { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index b458683..83fc1a9 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3884,6 +3884,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {t('wk.engWinkeyer')} + {t('wk.engK3ng')} {t('wk.engSerial')} {t('wk.engIcom')} {t('wk.engYaesu')} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index da70d90..07ed1ab 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -165,7 +165,7 @@ const en: Dict = { 'wk.escClears': 'ESC clears the callsign too (otherwise ESC only stops transmission)', '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 .', - '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.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.', @@ -593,7 +593,7 @@ const fr: Dict = { 'wk.escClears': "ÉCHAP efface aussi l'indicatif (sinon ÉCHAP arrête seulement la transmission)", '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 .", - '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.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.", diff --git a/internal/winkeyer/hostopen.go b/internal/winkeyer/hostopen.go index 0f7c66a..15d8c83 100644 --- a/internal/winkeyer/hostopen.go +++ b/internal/winkeyer/hostopen.go @@ -34,10 +34,25 @@ const ( 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 + 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 ) @@ -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") // 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) { +// 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 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 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 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 } lastErr = err 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 } -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. - time.Sleep(bootDelay) + time.Sleep(boot) drain(p) // Resync the command parser before asking it anything. diff --git a/internal/winkeyer/hostopen_test.go b/internal/winkeyer/hostopen_test.go index 29b8364..06f2e3a 100644 --- a/internal/winkeyer/hostopen_test.go +++ b/internal/winkeyer/hostopen_test.go @@ -95,7 +95,7 @@ func (f *fakeKeyer) SetMode(*serial.Mode) error { return nil } // left mid-command simply absorbed. func TestHostOpenFollowsK1ELSequence(t *testing.T) { f := &fakeKeyer{version: 23} - ver, err := hostOpen(f) + ver, err := hostOpen(f, false) if err != nil { t.Fatalf("hostOpen: %v", err) } @@ -123,7 +123,7 @@ func TestHostOpenFollowsK1ELSequence(t *testing.T) { // having to unplug anything. func TestHostOpenRecoversAConfusedParser(t *testing.T) { f := &fakeKeyer{version: 30, needsResync: true} - ver, err := hostOpen(f) + ver, err := hostOpen(f, false) if err != nil { t.Fatalf("hostOpen: %v", err) } @@ -137,7 +137,7 @@ func TestHostOpenRecoversAConfusedParser(t *testing.T) { // commands and a keyer that never made a sound. func TestHostOpenFailsWhenNothingAnswers(t *testing.T) { 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) } } @@ -146,7 +146,7 @@ func TestHostOpenFailsWhenNothingAnswers(t *testing.T) { // reported as "no keyer". func TestHostOpenReportsMissingVersion(t *testing.T) { f := &fakeKeyer{mute: true} - _, err := hostOpen(f) + _, err := hostOpen(f, false) if err == nil { 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) } } + +// 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) + } +} diff --git a/internal/winkeyer/winkeyer.go b/internal/winkeyer/winkeyer.go index 8e772b6..96f783a 100644 --- a/internal/winkeyer/winkeyer.go +++ b/internal/winkeyer/winkeyer.go @@ -53,6 +53,8 @@ type Config struct { // Type selects the keyer engine on this serial port: // "" / "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): // the "hardware CW keying" a Yaesu SCU-17 / generic interface // 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) } - 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 { _ = p.Close() return fmt.Errorf("winkeyer: %s: %w", cfg.Port, err)