diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 762fdf9..f988ad8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -811,6 +811,11 @@ export default function App() { const wkEscClearsRef = useRef(true); const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the wait-then-log useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]); + // Generation token for an in-flight macro send: bumped on every new send AND on + // any abort (ESC / Stop / callsign cleared). wkSend checks it before each CW + // segment and before every log, so aborting mid-macro stops sending AND + // skips the log that hasn't happened yet. + const wkSendGenRef = useRef(0); useEffect(() => { const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected) : cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected) @@ -2125,44 +2130,59 @@ export default function App() { return out.replace(/\s+/g, ' ').trim(); } async function wkSend(rawText: string) { + const gen = ++wkSendGenRef.current; // this send supersedes any previous; abort bumps it too setWkSent(''); - const resolved = resolveCW(rawText); - // Trailing word space so two macros fired back-to-back don't run together in - // the keyer buffer ("CQ" + "TEST" → "CQTEST"). The keyer keys a space as a - // word gap at the CURRENT speed, so it scales with WPM automatically. - const keyed = resolved ? resolved + ' ' : resolved; - const doLog = //i.test(rawText); // resolveCW strips the token (unknown var → "") const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms)); - if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') { - // The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so show - // the text we sent and, for , wait the estimated send duration - // before logging. - setWkSent(resolved); - const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW; - await sendFn(keyed).catch((e) => setError(String(e?.message ?? e))); - if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); } - return; + const aborted = () => gen !== wkSendGenRef.current; + // Cancellable wait: sleeps up to ms, but bails out immediately on abort. + const waitMs = async (ms: number) => { + const end = Date.now() + ms; + while (Date.now() < end && !aborted()) await sleep(50); + }; + // Split the macro on so the log happens AT the token's position, not + // only at the very end: "TU QRZ" sends "TU", logs, then sends "QRZ". + // Each split boundary (every part except the last) is one . + const parts = rawText.split(//i); + const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex'; + for (let p = 0; p < parts.length; p++) { + if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log + const resolved = resolveCW(parts[p]); + if (resolved) { + // Trailing word space so back-to-back sends don't run together in the keyer + // buffer ("CQ"+"TEST" → "CQTEST"); the keyer keys it as a word gap at the + // current WPM, so it scales automatically. + const keyed = resolved + ' '; + setWkSent(resolved); + if (isRig) { + // The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so + // wait the ESTIMATED send duration before moving on / logging. + const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW; + await sendFn(keyed).catch((e) => setError(String(e?.message ?? e))); + await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + (p < parts.length - 1 ? 200 : 600)); + } else { + await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e))); + // Wait for the keyer to actually finish this segment before we log / + // continue. We'd like to watch busy rise then fall, but over a + // remote/serial-over-IP link that echo can lag tens of seconds, so cap + // the wait at the ESTIMATED send duration + slack: proceed when busy + // clears OR the estimate elapses, whichever comes first. + const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; + for (let i = 0; i < 20 && !wkBusyRef.current && !aborted(); i++) await sleep(50); // ≤1s to start + const deadline = Date.now() + capMs; + while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50); + } + } + if (aborted()) return; // aborted while this segment was sending → don't log + // A token follows every part except the last → log the contact here. + if (p < parts.length - 1) void save(); } - await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e))); - // (e.g. "BK 73 TU ") logs the contact AFTER the keyer has - // finished sending — so the QSO isn't logged (and the form cleared) while CW - // is still going out. We'd like to wait for the busy flag to rise then fall, - // but over a remote/serial-over-IP link that status echo can lag by tens of - // seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED - // send duration (text length × WPM) plus slack: log when busy clears OR the - // estimate elapses, whichever comes first. - if (!doLog) return; - const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag - for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start - const deadline = Date.now() + capMs; - while (wkBusyRef.current && Date.now() < deadline) await sleep(50); - void save(); } // stopAutoCall cancels any running auto-call loop. function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; } // stopKeyerTx aborts the CW being sent RIGHT NOW, routed to the active engine // (was WinKeyer-only in a few places, so a Flex CWX / Icom macro kept going). function stopKeyerTx() { + wkSendGenRef.current++; // cancel any in-flight macro send (and its pending log) if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {}); else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); @@ -2902,6 +2922,7 @@ export default function App() { // ESC didn't stop the Icom or Flex keyer). if (keyerLive) { stopAutoCall(); + wkSendGenRef.current++; // abort an in-flight macro send so a pending won't fire if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {}); else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); @@ -4479,10 +4500,7 @@ export default function App() { }} onSend={wkSend} onSendMacro={wkSendMacro} - onStop={() => { stopAutoCall(); - if (cwSource === 'icom') IcomStopCW().catch(() => {}); - else if (cwSource === 'flex') FlexStopCW().catch(() => {}); - else WinkeyerStop().catch(() => {}); }} + onStop={() => { stopAutoCall(); stopKeyerTx(); }} onClose={() => wkSetEnabled(false)} sendOnType={wkSendOnType} onToggleSendOnType={wkToggleSendOnType}