From 08f3e54afb4482147e28feaa334770314e713f88 Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 29 Jul 2026 12:42:40 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20CW=20through=20the=20Yaesu=20keyer=20?= =?UTF-8?q?=E2=80=94=20fifth=20CW=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The radio has a keyer and a command to feed it (KY), so an FTDX10 needs no WinKeyer and no second cable, exactly as the Icom CI-V and Flex CWX engines already do. The rig keys with its own timing, which is why the spacing is right where a PC keying a line through USB latency drifts. Text is filtered to what the keyer can actually send: an unsupported byte does not produce an error on a Yaesu, it can abort the whole buffer, so the rest of a macro would vanish silently. It is then fed in 24-character pieces, waiting for room between them — the rig DROPS what does not fit, again with no error, so a contest CQ would lose its tail. STOP is the honest gap. Yaesu documents no buffer-clear, so it drops the transmitter (TX0) instead: nothing queued reaches the air, which is what Escape means to an operator. It deliberately does NOT send "KY0;" — a plausible-looking clear that the rig would read as the CHARACTER zero and transmit. A test caught a real one on the way: tabs and newlines were dropped as "unsupported", gluing the words either side together, so a macro written on two lines went out as CQCQ. Whitespace now becomes a word gap before filtering. Settings warn when the Yaesu keyer is selected without the Yaesu CAT backend — otherwise it simply never keys, with nothing on screen saying why. Send path follows the CAT reference and how Hamlib drives these rigs; NOT yet verified on the air. --- app.go | 21 ++++ changelog.json | 2 + frontend/src/App.tsx | 9 +- frontend/src/components/SettingsModal.tsx | 7 ++ frontend/src/components/WinkeyerPanel.tsx | 10 +- frontend/src/lib/i18n.tsx | 8 +- frontend/wailsjs/go/main/App.d.ts | 4 + frontend/wailsjs/go/main/App.js | 8 ++ internal/cat/yaesu_cw.go | 128 ++++++++++++++++++++++ internal/cat/yaesu_cw_test.go | 63 +++++++++++ internal/cat/yaesu_panel.go | 3 + 11 files changed, 252 insertions(+), 11 deletions(-) create mode 100644 internal/cat/yaesu_cw.go create mode 100644 internal/cat/yaesu_cw_test.go diff --git a/app.go b/app.go index 3a28be4..2b87673 100644 --- a/app.go +++ b/app.go @@ -15005,3 +15005,24 @@ func (a *App) SetYaesuBreakIn(on bool) error { func (a *App) YaesuZeroIn() error { return a.yaesuDo(func(y cat.YaesuController) error { return y.YaesuZeroIn() }) } + +// YaesuSendCW keys a CW message through the Yaesu's own keyer (CAT "KY"), so an +// FTDX10 needs no WinKeyer and no second cable. +func (a *App) YaesuSendCW(text string) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + err := a.cat.YaesuDo(func(y cat.YaesuController) error { return y.SendCW(text) }) + if err != nil { + applog.Printf("yaesu cw: YaesuSendCW(%q) failed: %v", text, err) + } + return err +} + +// YaesuStopCW aborts the message being sent. +func (a *App) YaesuStopCW() error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.YaesuDo(func(y cat.YaesuController) error { return y.StopCW() }) +} diff --git a/changelog.json b/changelog.json index ef87148..e61c039 100644 --- a/changelog.json +++ b/changelog.json @@ -4,6 +4,7 @@ "date": "2026-07-28", "en": [ "Yaesu control panel: with the native Yaesu backend, a console tab and Main-view pane give S/PO/SWR meters, band and mode buttons, AF/RF/squelch, AGC, IPO-AMP1-AMP2, a 6/12/18 dB attenuator, NB, DNR, narrow filter, power, mic gain, VOX, split and ATU tune. CW, RTTY, DIGI and PSK show their sideband — click an active button again to switch it. Pressing SPLIT places the transmit VFO relative to where you are listening — up 1 kHz on CW and data, up 5 kHz on phone — and shows the transmit frequency; +1k and +5k buttons cover the rest.", + "CW through the Yaesu keyer: a fifth CW engine sends your macros with the radio own keyer (CAT \"KY\") — no WinKeyer, no second cable. Pick \"Yaesu (rig keyer)\" in Settings → CW keyer, with the Yaesu CAT backend active.", "The Yaesu console follows the mode: in CW the microphone gain and VOX disappear and a CW card takes their place with keyer speed, break-in and ZIN (zero-in).", "Native Xiegu CAT (G90, X6100, X6200, X5105): a new backend talks CI-V straight to the radio — frequency, mode, split and PTT. Pick \"Xiegu (native CI-V)\" in Settings → CAT. Untested on a radio so far, feedback welcome.", "CAT sharing: OpsLog can now serve its rig connection to other programs (Settings → CAT → Share CAT). WSJT-X, JTDX, MSHV or Log4OM connect with rig model \"Hamlib NET rigctl\" at 127.0.0.1:4532 — needed because a native CAT backend holds the radio serial port on its own. Works with every backend, and with both Hamlib command dialects (JTDX names the VFO, MSHV does not).", @@ -18,6 +19,7 @@ ], "fr": [ "Panneau de contrôle Yaesu : avec le backend Yaesu natif, un onglet console et un volet de la vue principale offrent les mesures S/PO/ROS, les boutons de bande et de mode, AF/RF/squelch, AGC, IPO-AMP1-AMP2, un atténuateur 6/12/18 dB, NB, DNR, filtre étroit, puissance, gain micro, VOX, split et accord d'antenne. CW, RTTY, DIGI et PSK affichent leur bande latérale — recliquez sur le bouton actif pour en changer. Appuyer sur SPLIT place le VFO d'émission par rapport à votre écoute — +1 kHz en CW et numérique, +5 kHz en phonie — et affiche la fréquence d'émission ; les boutons +1k et +5k couvrent le reste.", + "CW par le keyer Yaesu : un cinquième moteur CW envoie vos macros avec le keyer de la radio (CAT « KY ») — sans WinKeyer ni second câble. À choisir dans Réglages → Keyer CW sous « Yaesu (keyer de la radio) », avec le backend CAT Yaesu actif.", "La console Yaesu suit le mode : en CW, le gain micro et le VOX disparaissent au profit d'une carte CW avec la vitesse du manipulateur, le break-in et ZIN (zéro-in).", "CAT Xiegu natif (G90, X6100, X6200, X5105) : un nouveau backend dialogue en CI-V directement avec la radio — fréquence, mode, split et PTT. À choisir dans Réglages → CAT sous « Xiegu (CI-V natif) ». Pas encore testé sur une radio, retours bienvenus.", "Partage du CAT : OpsLog peut désormais servir sa liaison radio aux autres logiciels (Réglages → CAT → Partager le CAT). WSJT-X, JTDX, MSHV ou Log4OM s'y connectent avec le modèle « Hamlib NET rigctl » sur 127.0.0.1:4532 — nécessaire car un backend CAT natif occupe seul le port série. Fonctionne avec tous les backends, et avec les deux dialectes Hamlib (JTDX nomme le VFO, MSHV non).", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18902d5..6a9d1c4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,7 +38,7 @@ import { ListCountries, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus, WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace, - IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState, + IcomSendCW, YaesuSendCW, YaesuStopCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState, FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, StartCWDecoder, StopCWDecoder, SetCWDecoderPitch, @@ -965,7 +965,7 @@ export default function App() { // CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros, // auto-call and are shared; only the transport differs. const [wkEngine, setWkEngine] = useState('winkeyer'); - const cwSource: 'winkeyer' | 'icom' | 'flex' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : 'winkeyer'; + const cwSource: 'winkeyer' | 'icom' | 'flex' | 'yaesu' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : wkEngine === 'yaesu' ? 'yaesu' : 'winkeyer'; const cwSourceRef = useRef(cwSource); useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]); // CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to @@ -1001,6 +1001,7 @@ export default function App() { useEffect(() => { const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected) : cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected) + : cwSource === 'yaesu' ? (catState.backend === 'yaesu' && catState.connected) : wkStatus.connected; wkActiveRef.current = wkEnabled && connected; }, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]); @@ -2558,7 +2559,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 : null; + const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : null; if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e))); else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e))); // Only WAIT for the CW to finish on the FINAL segment — auto-call needs the @@ -2591,6 +2592,7 @@ export default function App() { 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 if (cwSourceRef.current === 'yaesu') YaesuStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); } // runAutoCall sends macro i, waits for the keyer to finish, waits the chosen @@ -3495,6 +3497,7 @@ export default function App() { 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 === 'yaesu') YaesuStopCW().catch(() => {}); else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); } diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 0e99083..570bc37 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3309,6 +3309,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {t('wk.engWinkeyer')} {t('wk.engSerial')} {t('wk.engIcom')} + {t('wk.engYaesu')} {t('wk.engFlex')} {t('wk.engTci')} @@ -3324,6 +3325,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan + {wk.engine === 'yaesu' && (!catCfg.enabled || catCfg.backend !== 'yaesu') && ( +

+ + {t('wk.catWarnYaesu', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })} +

+ )} {wk.engine === 'icom' ? ( <> {(!catCfg.enabled || catCfg.backend !== 'icom') && ( diff --git a/frontend/src/components/WinkeyerPanel.tsx b/frontend/src/components/WinkeyerPanel.tsx index dfafd70..f5a6749 100644 --- a/frontend/src/components/WinkeyerPanel.tsx +++ b/frontend/src/components/WinkeyerPanel.tsx @@ -26,7 +26,7 @@ interface Props { wpm: number; macros: WKMacro[]; sent: string; // text echoed back by the keyer as it transmits - source: 'winkeyer' | 'icom' | 'flex'; // CW output engine (chosen in Settings → CW Keyer) + source: 'winkeyer' | 'icom' | 'flex' | 'yaesu'; // CW output engine (chosen in Settings → CW Keyer) breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL onSetBreakIn?: (mode: number) => void; onSelectPort: (p: string) => void; @@ -101,16 +101,18 @@ export function WinkeyerPanel({ {/* CW output engine (chosen in Settings → CW Keyer). */} - {source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : 'WinKeyer'} + {source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : 'WinKeyer'}
- {source === 'icom' || source === 'flex' ? ( + {source === 'icom' || source === 'flex' || source === 'yaesu' ? ( {source === 'flex' ? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline')) - : (connected ? t('wkp.civReady') : t('wkp.civOffline'))} + : source === 'yaesu' + ? (connected ? t('wkp.rigReady') : t('wkp.rigOffline')) + : (connected ? t('wkp.civReady') : t('wkp.civOffline'))} ) : !connected ? ( <> diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 28a4139..ef53542 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -163,7 +163,7 @@ const en: Dict = { '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.engWinkeyer': 'WinKeyer (K1EL, serial)', 'wk.engSerial': 'Serial port (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (rig keyer)', 'wk.engYaesu': 'Yaesu (rig keyer)', 'wk.catWarnYaesu': 'The Yaesu keyer needs the Yaesu CAT backend — CAT is currently {backend}.', '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).", 'wk.catWarnIcom': 'Your CAT backend is set to {backend}. Icom CI-V CW needs the CAT backend set to Icom and connected — change it under Settings → CAT interface, otherwise sending CW will fail.', @@ -346,7 +346,7 @@ const en: Dict = { 'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field', // Hardware panels (winkeyer / dvk / antgenius / flex / icom) 'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer', - 'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rig’s own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)', 'wkp.cwxReady': 'Flex CWX ready', 'wkp.cwxOffline': 'Flex not connected (Settings → CAT)', + 'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rig’s own keyer (over CAT, no extra hardware)', 'wkp.rigReady': 'Rig keyer ready', 'wkp.rigOffline': 'Rig CAT offline', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)', 'wkp.cwxReady': 'Flex CWX ready', 'wkp.cwxOffline': 'Flex not connected (Settings → CAT)', 'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop', 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL", 'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}', @@ -574,7 +574,7 @@ const fr: Dict = { '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.engWinkeyer': 'WinKeyer (K1EL, série)', 'wk.engSerial': 'Port série (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (keyer de la radio)', 'wk.engYaesu': 'Yaesu (keyer de la radio)', 'wk.catWarnYaesu': 'Le keyer Yaesu nécessite le backend CAT Yaesu — le CAT est actuellement : {backend}.', '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).", 'wk.catWarnIcom': "Ton backend CAT est réglé sur {backend}. La CW Icom CI-V nécessite le backend CAT réglé sur Icom et connecté — change-le dans Réglages → Interface CAT, sinon l'envoi CW échouera.", @@ -741,7 +741,7 @@ const fr: Dict = { 'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande', 'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ', 'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer', - 'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de l’Icom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)', 'wkp.cwxReady': 'Flex CWX prêt', 'wkp.cwxOffline': 'Flex non connecté (Réglages → CAT)', + 'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de l’Icom (via CAT, sans matériel en plus)', 'wkp.rigReady': 'Keyer radio prêt', 'wkp.rigOffline': 'CAT radio hors ligne', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)', 'wkp.cwxReady': 'Flex CWX prêt', 'wkp.cwxOffline': 'Flex non connecté (Réglages → CAT)', 'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop', 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL", 'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 1bea998..2d21ff4 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -1035,4 +1035,8 @@ export function WinkeyerStop():Promise; export function WorkedBefore(arg1:string,arg2:number):Promise; +export function YaesuSendCW(arg1:string):Promise; + +export function YaesuStopCW():Promise; + export function YaesuZeroIn():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 93bcd1c..c3fef2d 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -2018,6 +2018,14 @@ export function WorkedBefore(arg1, arg2) { return window['go']['main']['App']['WorkedBefore'](arg1, arg2); } +export function YaesuSendCW(arg1) { + return window['go']['main']['App']['YaesuSendCW'](arg1); +} + +export function YaesuStopCW() { + return window['go']['main']['App']['YaesuStopCW'](); +} + export function YaesuZeroIn() { return window['go']['main']['App']['YaesuZeroIn'](); } diff --git a/internal/cat/yaesu_cw.go b/internal/cat/yaesu_cw.go new file mode 100644 index 0000000..d7edbb2 --- /dev/null +++ b/internal/cat/yaesu_cw.go @@ -0,0 +1,128 @@ +package cat + +// CW keying through the Yaesu's own keyer — the KY command. +// +// This is the fifth CW engine, alongside WinKeyer, the DTR/RTS line keyer, the +// Icom CI-V keyer and FlexRadio's CWX. The point is the same in each: no extra +// hardware, no second cable. The radio holds the text and keys it with its own +// timing, which is why the character spacing is perfect where a PC keying a line +// through USB latency is not. +// +// KY; → KY0; buffer has room / KY1; buffer full +// KY ; queue up to 24 characters +// +// The leading SPACE after KY is part of the command, not padding. +// +// Verified on: nothing yet — the send path follows the FTDX10/FTDX101 CAT +// reference and the way Hamlib drives these rigs. STOP is the uncertain half: +// Yaesu documents no way to clear the buffer, so see StopCW. + +import ( + "fmt" + "strings" + "time" +) + +// yaesuCWChunk is the most characters one KY command accepts. Longer text is +// split and fed as the rig drains its buffer. +const yaesuCWChunk = 24 + +// yaesuCWAllowed is what the rig's keyer can actually send. Anything else is +// dropped rather than passed through: an unsupported byte can abort the whole +// buffer, losing the rest of the message with no error anywhere. +const yaesuCWAllowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 /?.,-=+:;()" + +// SendCW queues a message on the rig's keyer. +// +// Text is filtered, upper-cased and fed in 24-character pieces, waiting for room +// between pieces. Without that wait a long macro would silently lose its tail: +// the rig drops what does not fit rather than reporting an error. +func (y *Yaesu) SendCW(text string) error { + msg := filterYaesuCW(text) + if msg == "" { + return nil + } + for len(msg) > 0 { + n := yaesuCWChunk + if len(msg) < n { + n = len(msg) + } + chunk := msg[:n] + msg = msg[n:] + if err := y.waitCWBuffer(4 * time.Second); err != nil { + return err + } + y.mu.Lock() + err := y.write("KY " + chunk + ";") + y.mu.Unlock() + if err != nil { + return err + } + } + return nil +} + +// StopCW aborts the message being sent. +// +// Yaesu documents no buffer-clear, so this drops the transmitter instead: TX0 +// takes the rig out of transmit, which is what an operator pressing Escape +// actually wants. Anything still queued is not keyed on the air. +// +// It deliberately does NOT send "KY0;" — a plausible-looking clear that the rig +// would read as the CHARACTER zero and dutifully send. A wrong guess here +// transmits, which is worse than an imperfect abort. +func (y *Yaesu) StopCW() error { + y.mu.Lock() + defer y.mu.Unlock() + if y.port == nil { + return fmt.Errorf("yaesu: not connected") + } + return y.write("TX0;") +} + +// waitCWBuffer blocks until the keyer reports room, or the deadline passes. +// A rig that never answers the status query is not a reason to refuse to send — +// we go ahead once, and the worst case is the truncation we were avoiding. +func (y *Yaesu) waitCWBuffer(within time.Duration) error { + deadline := time.Now().Add(within) + for { + y.mu.Lock() + if y.port == nil { + y.mu.Unlock() + return fmt.Errorf("yaesu: not connected") + } + r, err := y.ask("KY;") + y.mu.Unlock() + if err != nil { + debugLog.Printf("yaesu cw: KY status query failed (%v) — sending anyway", err) + return nil + } + if len(r) >= 3 && r[2] == '0' { + return nil // room in the buffer + } + if time.Now().After(deadline) { + return fmt.Errorf("yaesu: the keyer buffer stayed full for %s", within) + } + time.Sleep(50 * time.Millisecond) + } +} + +// filterYaesuCW upper-cases and strips what the keyer cannot send. +func filterYaesuCW(text string) string { + var b strings.Builder + for _, r := range strings.ToUpper(text) { + // Any whitespace becomes a word gap FIRST. Dropping tabs and newlines as + // "not in the allowed set" glued the words either side together: a macro + // written on two lines went out as CQCQ. + if r == '\t' || r == '\n' || r == '\r' { + b.WriteByte(' ') + continue + } + if strings.ContainsRune(yaesuCWAllowed, r) { + b.WriteRune(r) + } + } + // Runs of spaces become one: the rig sends each as a word gap, so three in a + // row is three gaps and the message crawls. + return strings.Join(strings.Fields(b.String()), " ") +} diff --git a/internal/cat/yaesu_cw_test.go b/internal/cat/yaesu_cw_test.go new file mode 100644 index 0000000..f28f2ef --- /dev/null +++ b/internal/cat/yaesu_cw_test.go @@ -0,0 +1,63 @@ +package cat + +import ( + "strings" + "testing" +) + +// What reaches the rig's keyer. +// +// An unsupported byte does not produce an error on a Yaesu — it can abort the +// whole buffer, so the rest of the message is lost silently. Filtering is +// therefore part of correctness, not tidiness. +func TestFilterYaesuCW(t *testing.T) { + cases := []struct{ in, want string }{ + {"cq cq de f4bpo", "CQ CQ DE F4BPO"}, + {"F4BPO/P", "F4BPO/P"}, + {"599 tu 73", "599 TU 73"}, + // Accents and symbols the keyer has no code for. + {"héllo", "HLLO"}, + {"a*b#c", "ABC"}, + // Runs of whitespace collapse: the rig sends each space as a word gap, so + // three in a row is three gaps and the message crawls. + {"cq cq", "CQ CQ"}, + {" padded ", "PADDED"}, + {"\tcq\ncq\t", "CQ CQ"}, + {"", ""}, + {" ", ""}, + } + for _, c := range cases { + if got := filterYaesuCW(c.in); got != c.want { + t.Errorf("filterYaesuCW(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// Long macros have to be split: one KY command carries 24 characters and the rig +// DROPS what does not fit rather than reporting an error, so a contest CQ would +// lose its tail with no sign anywhere. +func TestYaesuCWChunking(t *testing.T) { + msg := filterYaesuCW("cq cq cq de f4bpo f4bpo f4bpo pse k") + if len(msg) <= yaesuCWChunk { + t.Fatalf("test message is %d chars — too short to exercise chunking", len(msg)) + } + var chunks []string + for rest := msg; len(rest) > 0; { + n := yaesuCWChunk + if len(rest) < n { + n = len(rest) + } + chunks = append(chunks, rest[:n]) + rest = rest[n:] + } + for i, c := range chunks { + if len(c) > yaesuCWChunk { + t.Errorf("chunk %d is %d chars, over the %d limit", i, len(c), yaesuCWChunk) + } + } + // Nothing added, nothing lost: the message the operator typed is what the + // keyer receives. + if joined := strings.Join(chunks, ""); joined != msg { + t.Errorf("chunks rejoin to %q, want %q", joined, msg) + } +} diff --git a/internal/cat/yaesu_panel.go b/internal/cat/yaesu_panel.go index 2173b3f..71325a6 100644 --- a/internal/cat/yaesu_panel.go +++ b/internal/cat/yaesu_panel.go @@ -82,6 +82,9 @@ type YaesuController interface { SetYaesuKeySpeed(int) error SetYaesuBreakIn(bool) error YaesuZeroIn() error + // CW keying through the rig's own keyer (KY) — the fifth CW engine. + SendCW(string) error + StopCW() error SetYaesuSplit(bool) error SetYaesuSplitOffset(int64) error SetYaesuBand(string) error