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
+5 -1
View File
@@ -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),
} {
+2
View File
@@ -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 <LOGQSO>.",
"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 <LOGQSO>.",
"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.",
+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).
+9 -2
View File
@@ -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<WKSettings>({
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
<Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
{t('wk.escClears')}
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
<Checkbox checked={wk.esm} onCheckedChange={(c) => setWkField({ esm: !!c })} className="mt-0.5" />
<span>
{t('wk.esm')}
<span className="block text-xs text-muted-foreground">{t('wk.esmHint')}</span>
</span>
</label>
</div>
{wk.engine === 'icom' ? (
+4
View File
@@ -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 <LOGQSO>.',
'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 <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.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).",
+2
View File
@@ -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);
}