feat(tci): key CW through the radio's own macro keyer

The sixth CW engine, and the one the TCI backend was left without. A SunSDR
keys from the WebSocket that already carries the CAT: no WinKeyer, no second
serial port, nothing to wire.

    CW_MACROS:0,<text>;     send
    CW_MACROS_SPEED:<wpm>;  speed
    CW_MACROS_STOP;         abort

Confirmed against the TCI command table in ars-ka0s/eesdr-tci — the source that
settled SPOT. CW_MACROS rather than TCI 2.0's CW_MSG: it exists from 1.6, and
OpsLog resolves its own variables, so the before/after callsign fields have
nothing to carry.

Commas and semicolons are stripped before sending. They are the protocol's own
separators: a comma would become another argument, a semicolon would end the
command with the message half keyed and the rest parsed as a command.

Only the MACRO speed is set, not the paddle keyer's — an operator who set their
paddle to 28 wpm did not ask for it to change because a macro went out at 25.
And there is no backspace: TCI can stop a message but cannot un-type one, so the
type-ahead correction the Flex engine offers is absent rather than faked.
This commit is contained in:
2026-08-28 13:01:55 +02:00
parent 8bc4ed68d4
commit 766b0f95a4
11 changed files with 200 additions and 10 deletions
+11 -3
View File
@@ -41,6 +41,7 @@ import {
IcomSendCW, YaesuSendCW, YaesuStopCW, SetYaesuKeySpeed, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
KenwoodSendCW, KenwoodStopCW, SetKenwoodKeySpeed,
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
TCISendCW, TCIStopCW, TCISetKeySpeed,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
@@ -1490,7 +1491,7 @@ export default function App() {
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
// auto-call and <LOGQSO> are shared; only the transport differs.
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : wkEngine === 'kenwood' ? 'kenwood' : 'winkeyer';
const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood' | 'tci' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : wkEngine === 'kenwood' ? 'kenwood' : wkEngine === 'tci' ? 'tci' : 'winkeyer';
// Setting the CW speed has to reach the keyer that is ACTUALLY sending, and
// both the CW panel and the Yaesu console can ask for it. With DTR/RTS line
// keying the PC does the timing, so the rig's internal keyer speed changes
@@ -1504,6 +1505,7 @@ export default function App() {
else if (src === 'flex') FlexSetKeySpeed(w).catch(() => {});
else if (src === 'yaesu') SetYaesuKeySpeed(w).catch(() => {});
else if (src === 'kenwood') SetKenwoodKeySpeed(w).catch(() => {});
else if (src === 'tci') TCISetKeySpeed(w).catch(() => {});
else WinkeyerSetSpeed(w).catch(() => {});
// The rig's own keyer follows too whenever a Yaesu is on CAT, even when it is
// not the sending engine: its front panel and OpsLog then agree.
@@ -1548,6 +1550,7 @@ export default function App() {
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
: cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected)
: cwSource === 'kenwood' ? (catState.backend === 'kenwood' && catState.connected)
: cwSource === 'tci' ? (catState.backend === 'tci' && catState.connected)
: wkStatus.connected;
wkActiveRef.current = wkEnabled && connected;
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
@@ -3968,7 +3971,7 @@ export default function App() {
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
// variables correctly.
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex' || cwSourceRef.current === 'yaesu' || cwSourceRef.current === 'kenwood';
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex' || cwSourceRef.current === 'yaesu' || cwSourceRef.current === 'kenwood' || cwSourceRef.current === 'tci';
for (let p = 0; p < parts.length; p++) {
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
const resolved = parts[p];
@@ -3980,7 +3983,7 @@ export default function App() {
// current WPM, so it scales automatically.
const keyed = resolved + ' ';
setWkSent(resolved);
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : cwSourceRef.current === 'kenwood' ? KenwoodSendCW : null;
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : cwSourceRef.current === 'kenwood' ? KenwoodSendCW : cwSourceRef.current === 'tci' ? TCISendCW : null;
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
// WAIT for THIS segment's CW to finish before moving on — so a <LOGQSO>
@@ -4023,6 +4026,7 @@ export default function App() {
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
else if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {});
else if (cwSourceRef.current === 'kenwood') KenwoodStopCW().catch(() => {});
else if (cwSourceRef.current === 'tci') TCIStopCW().catch(() => {});
else WinkeyerStop().catch(() => {});
}
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
@@ -4071,6 +4075,10 @@ export default function App() {
// send-on-type: key the typed chars verbatim (no variable substitution).
function wkSendRaw(chars: string) {
if (cwSourceRef.current === 'flex') { FlexSendCW(chars).catch(() => {}); return; }
// TCI keys the character as a macro of its own. There is no un-typing it
// afterwards — the radio can stop the message but not shorten it — so the
// backspace below leaves the TCI engine alone rather than pretending.
if (cwSourceRef.current === 'tci') { TCISendCW(chars).catch(() => {}); return; }
WinkeyerSend(chars).catch(() => {});
}
function wkBackspace() {