feat: ESM (Enter Sends Message) for CW keyers, N1MM-style

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 <LOGQSO>

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.
This commit is contained in:
2026-07-25 12:38:09 +02:00
parent e5ff30823d
commit 370fde42f7
6 changed files with 58 additions and 6 deletions
+36 -3
View File
@@ -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 <LOGQSO>
// 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 <LOGQSO> 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 = (
<div className="flex flex-col w-44">
<div className="flex flex-col w-44" data-esm="call">
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
{t('field.callsign')}
{lookupBusy && (
@@ -3284,12 +3314,12 @@ export default function App() {
</div>
);
const rstTxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
<div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
</div>
);
const rstRxBlock = (
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
</div>
);
@@ -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).