From 370fde42f7fdeb951f1d76b735801bbb6b0ee86b Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 25 Jul 2026 12:38:09 +0200 Subject: [PATCH] feat: ESM (Enter Sends Message) for CW keyers, N1MM-style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With ESM enabled (Settings → CW Keyer), the keyer active and mode CW, Enter in the entry strip fires a macro by QSO stage instead of logging: - callsign field empty → F1 (CQ) - callsign entered (in call field) → F2 (report), then focus jumps to RST-sent - Enter in RST-sent / RST-rcvd → F3 (TU), which logs via its own Works for every keyer engine (WinKeyer / serial DTR-RTS / Flex CWX / Icom CI-V) since it routes through the shared macro sender. Enter in other fields still logs normally, and ESM off keeps the classic Enter-to-log behaviour. Backend: new `esm` flag on WinkeyerSettings (winkeyer.esm). Frontend: esmHandleEnter state machine keyed off data-esm markers on the call/RST blocks, a Settings checkbox + hint, and i18n EN/FR. --- app.go | 6 +++- changelog.json | 2 ++ frontend/src/App.tsx | 39 +++++++++++++++++++++-- frontend/src/components/SettingsModal.tsx | 11 +++++-- frontend/src/lib/i18n.tsx | 4 +++ frontend/wailsjs/go/models.ts | 2 ++ 6 files changed, 58 insertions(+), 6 deletions(-) diff --git a/app.go b/app.go index 48a6677..1769f0c 100644 --- a/app.go +++ b/app.go @@ -228,6 +228,7 @@ const ( keyWKEngine = "winkeyer.engine" // "winkeyer" | "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) keyWKCWLine = "winkeyer.cw_key_line" // serial engine: "dtr" (CW) / "rts" (PTT) or swapped keyWKCWInvert = "winkeyer.cw_invert" // serial engine: invert line polarity (active-LOW) @@ -13485,6 +13486,7 @@ type WinkeyerSettings struct { Engine string `json:"engine"` // keyer backend: "winkeyer" | "icom" (rig keyer via CI-V) | "tci" EscClearsCall bool `json:"esc_clears_call"` // ESC also resets the callsign SendOnType bool `json:"send_on_type"` // key chars live as typed + Esm bool `json:"esm"` // Enter-Sends-Message: Enter fires F1/F2/F3 by QSO stage Macros []WKMacro `json:"macros"` } @@ -13512,7 +13514,7 @@ func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) { keyWKEnabled, keyWKPort, keyWKBaud, keyWKWPM, keyWKWeight, keyWKLeadIn, keyWKTail, keyWKRatio, keyWKFarnsworth, keyWKSidetone, keyWKMode, keyWKSwap, keyWKAutoSpace, keyWKUsePTT, keyWKSerialEcho, keyWKMacros, - keyWKEngine, keyWKEscClears, keyWKSendOnType, keyWKCWLine, keyWKCWInvert) + keyWKEngine, keyWKEscClears, keyWKSendOnType, keyWKEsm, keyWKCWLine, keyWKCWInvert) if err != nil { return out, err } @@ -13527,6 +13529,7 @@ func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) { out.EscClearsCall = v == "1" } out.SendOnType = m[keyWKSendOnType] == "1" + out.Esm = m[keyWKEsm] == "1" out.Enabled = m[keyWKEnabled] == "1" if v := m[keyWKPort]; v != "" { out.Port = v @@ -13589,6 +13592,7 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error { keyWKEngine: strings.TrimSpace(s.Engine), keyWKEscClears: boolStr(s.EscClearsCall), keyWKSendOnType: boolStr(s.SendOnType), + keyWKEsm: boolStr(s.Esm), keyWKCWLine: strings.TrimSpace(s.CWKeyLine), keyWKCWInvert: boolStr(s.CWInvert), } { diff --git a/changelog.json b/changelog.json index 6fe2827..340ab8c 100644 --- a/changelog.json +++ b/changelog.json @@ -3,6 +3,7 @@ "version": "0.21.1", "date": "2026-07-24", "en": [ + "New: ESM (Enter Sends Message) for CW, N1MM-style. Enable it in Settings → CW Keyer. With the keyer on in CW, 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 the QSO if the macro contains .", "The top frequency readout is now scroll-tunable: roll the mouse wheel over the hundreds / tens / units-of-kHz digit to step the frequency by 100 / 10 / 1 kHz, and the rig follows over CAT.", "New: 4O3A Tuner Genius XL control. Enable it in Settings → Tuner Genius (IP only; port is fixed at 9010). Live SWR and forward power with Tune, Bypass and Operate/Standby, the two channels A/B (source, frequency and antenna) shown and click-selectable. Available as a docked widget, a card in the FlexRadio panel (like the PowerGenius) and a card in Station Control. Controlled directly over TCP, so it uses just one of the box's connection slots.", "FlexRadio panel tidy-up: cards collapse from the chevron in their header (Transmit and Receive fold together), all meters are the same size, the MIC and COMP meters only show in phone modes, and the S-meter dBm now sits next to the S-value instead of on a second line.", @@ -14,6 +15,7 @@ "Fixed award references (in the entry strip's Awards tab) not clearing when the callsign changes — clicking one spot then another no longer keeps the previous station's references." ], "fr": [ + "Nouveau : ESM (Entrée envoie le message) en CW, façon N1MM. À activer dans Réglages → Keyer CW. Avec le keyer actif en CW, 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 le QSO si le macro contient .", "L'affichage de fréquence en haut est maintenant accordable à la molette : roule la molette sur le chiffre des centaines / dizaines / unités de kHz pour changer la fréquence par pas de 100 / 10 / 1 kHz, et la radio suit en CAT.", "Nouveau : contrôle du 4O3A Tuner Genius XL. Active-le dans Réglages → Tuner Genius (IP seulement ; port fixé à 9010). ROS et puissance directe en direct avec Accord, Bypass et Operate/Standby, et les deux canaux A/B (source, fréquence et antenne) affichés et sélectionnables d'un clic. Disponible en widget ancré, en carte dans le panneau FlexRadio (comme le PowerGenius) et en carte dans Station Control. Piloté directement en TCP, il n'utilise qu'une des connexions de la boîte.", "Nettoyage du panneau FlexRadio : les cartes se replient via le chevron de leur en-tête (Transmit et Receive se replient ensemble), tous les meters ont la même taille, les meters MIC et COMP ne s'affichent qu'en phonie, et le dBm du S-mètre est maintenant à côté de la valeur S plutôt que sur une deuxième ligne.", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fd267b..5bb5ed5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -840,6 +840,9 @@ export default function App() { const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed + const [wkEsm, setWkEsm] = useState(false); // Enter-Sends-Message (N1MM-style CW flow) + const wkEsmRef = useRef(false); + useEffect(() => { wkEsmRef.current = wkEsm; }, [wkEsm]); // CW keyer output engine (persisted in the WinKeyer settings, chosen in // Settings → CW Keyer): the WinKeyer hardware, or the Icom rig's own keyer via // CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros, @@ -2237,6 +2240,7 @@ export default function App() { setWkMacros((s.macros ?? []) as WKMacro[]); setWkEscClears(s.esc_clears_call !== false); setWkSendOnType(!!s.send_on_type); + setWkEsm(!!s.esm); setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : s.engine === 'serial' ? 'serial' : 'winkeyer'); } catch { /* keyer not configured */ } }, []); @@ -2443,6 +2447,32 @@ export default function App() { WinkeyerBackspace().catch(() => {}); } function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); } + function wkToggleEsm(on: boolean) { setWkEsm(on); saveWk({ esm: on }); } + // ESM (Enter Sends Message): N1MM-style CW flow. When ESM is on, the CW keyer is + // active and we're in CW, Enter fires a macro by QSO stage instead of logging: + // • callsign empty → F1 (CQ) + // • callsign entered → F2 (report), then focus jumps to RST-sent + // • focus in RST-sent/rcvd → F3 (TU) — which logs IF the macro has + // Returns true when it handled the Enter (so the caller skips the plain log). + function esmHandleEnter(target: HTMLElement): boolean { + if (!(wkEsmRef.current && wkActiveRef.current && (mode || '').toUpperCase().includes('CW'))) return false; + const field = target.closest('[data-esm]')?.getAttribute('data-esm'); + if (field === 'rsttx' || field === 'rstrx') { + wkSendMacro(2); // F3 (TU) — logs via its own if present + return true; + } + if (field === 'call') { + if (callsignValRef.current.trim() === '') { + wkSendMacro(0); // F1 (CQ) + return true; + } + wkSendMacro(1); // F2 (report) + // Move focus to RST-sent so the next Enter fires F3. + (document.querySelector('[data-esm="rsttx"] input') as HTMLInputElement | null)?.focus(); + return true; + } + return false; // any other field → normal behaviour (log on Enter) + } // Resolve slot status for any spot we haven't seen yet — debounced so we // don't hammer the backend at firehose rate. The mode passed to the @@ -3211,7 +3241,7 @@ export default function App() { // them as shared consts avoids duplicating the (large) per-field JSX + // handlers across the two layouts. const callsignBlock = ( -
+
); const rstTxBlock = ( -
+
{ setRstSent(v); rstUserEditedRef.current = true; }} />
); const rstRxBlock = ( -
+
{ setRstRcvd(v); rstUserEditedRef.current = true; }} />
); @@ -4561,6 +4591,9 @@ export default function App() { onKeyDown={(e) => { if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') { e.preventDefault(); + // ESM (Enter Sends Message): fire the stage-appropriate CW macro instead + // of logging. Falls through to the normal log when ESM isn't active. + if (esmHandleEnter(e.target as HTMLElement)) return; save(); } // ESC is handled globally (stop CW + optional callsign reset). diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index e30cae6..47a7ea3 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1076,14 +1076,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan // WinKeyer CW keyer settings + macro editor. type WKMac = { label: string; text: string }; type WKSettings = { - enabled: boolean; engine: string; esc_clears_call: boolean; + enabled: boolean; engine: string; esc_clears_call: boolean; esm: boolean; port: string; baud: number; wpm: number; weight: number; lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number; sidetone_hz: number; mode: string; swap: boolean; autospace: boolean; use_ptt: boolean; serial_echo: boolean; cw_key_line: string; cw_invert: boolean; macros: WKMac[]; }; const [wk, setWk] = useState({ - enabled: false, engine: 'winkeyer', esc_clears_call: true, + enabled: false, engine: 'winkeyer', esc_clears_call: true, esm: false, port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10, tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b', swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [], @@ -3106,6 +3106,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan setWkField({ esc_clears_call: !!c })} /> {t('wk.escClears')} +
{wk.engine === 'icom' ? ( diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index be06e49..91c6673 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -143,6 +143,8 @@ const en: Dict = { // CW Keyer settings panel 'wk.enable': 'Enable CW keyer (shows the keyer panel)', 'wk.engine': 'Keyer engine', '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.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).", @@ -517,6 +519,8 @@ const fr: Dict = { // Panneau Manipulateur CW 'wk.enable': 'Activer le manipulateur CW (affiche le panneau)', 'wk.engine': 'Moteur du manipulateur', '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.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).", diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 5f46564..f7f8426 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -3085,6 +3085,7 @@ export namespace main { engine: string; esc_clears_call: boolean; send_on_type: boolean; + esm: boolean; macros: WKMacro[]; static createFrom(source: any = {}) { @@ -3114,6 +3115,7 @@ export namespace main { this.engine = source["engine"]; this.esc_clears_call = source["esc_clears_call"]; this.send_on_type = source["send_on_type"]; + this.esm = source["esm"]; this.macros = this.convertValues(source["macros"], WKMacro); }