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:
@@ -14619,6 +14619,36 @@ func (a *App) FlexBackspaceCW(n int) error {
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.BackspaceCW(n) })
|
||||
}
|
||||
|
||||
// TCISendCW keys a CW message through the SunSDR's own macro keyer, so a TCI
|
||||
// station needs no WinKeyer and no second serial port. Text is already
|
||||
// variable-resolved by the UI.
|
||||
func (a *App) TCISendCW(text string) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
err := a.cat.TCICWDo(func(tc cat.TCICWController) error { return tc.SendCW(text) })
|
||||
if err != nil {
|
||||
applog.Printf("tci cw: TCISendCW(%q) failed: %v", text, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// TCIStopCW aborts whatever the keyer is sending.
|
||||
func (a *App) TCIStopCW() error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.TCICWDo(func(tc cat.TCICWController) error { return tc.StopCW() })
|
||||
}
|
||||
|
||||
// TCISetKeySpeed sets the macro keyer speed in WPM.
|
||||
func (a *App) TCISetKeySpeed(wpm int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.TCICWDo(func(tc cat.TCICWController) error { return tc.SetCWSpeed(wpm) })
|
||||
}
|
||||
|
||||
// IcomStopCW aborts the CW message currently being sent.
|
||||
func (a *App) IcomStopCW() error {
|
||||
if a.cat == nil {
|
||||
|
||||
+4
-2
@@ -6,13 +6,15 @@
|
||||
"LoTW download: the QSL details (QSL date, grid, state, county) are now optional and off by default — LoTW takes about ten times longer to build that report, twenty minutes against two on the same account, and marking a confirmation needs none of it. Still asked for automatically when adding the QSOs not found in the log.",
|
||||
"Band map: the tooltip now also names a new prefix or a new grid square. They stay off the 22-pixel colour strip, but leaving them out of the text made the two panels look as though they disagreed — the cluster said NEW PFX about a spot the map called Worked, and both were right. The map’s “Worked” also says whose: it is the ENTITY that was worked on that band and mode, not the callsign, which is what made the two readings look contradictory.",
|
||||
"TCI (SunSDR): clicking a cluster spot no longer needs a second click to get the mode right — the sideband was chosen from the frequency the radio had last reported instead of the one just asked for. The log also names the ExpertSDR version now, and says so when it is older than the 1.5 that panorama spots need.",
|
||||
"Two screens: OpsLog no longer walks off the desktop. On a monitor placed left of the primary one, the saved position was being added to that monitor’s own origin at every launch, so the window moved one screen further out each time until it was invisible."
|
||||
"Two screens: OpsLog no longer walks off the desktop. On a monitor placed left of the primary one, the saved position was being added to that monitor’s own origin at every launch, so the window moved one screen further out each time until it was invisible.",
|
||||
"CW over TCI: a SunSDR can now be keyed through its own macro keyer — pick TCI as the keyer engine (Settings → CW Keyer) and macros, auto-call and the speed control all work over the link already open, with no WinKeyer and no second serial port. NOT TESTED on the air yet."
|
||||
],
|
||||
"fr": [
|
||||
"Téléchargement LoTW : les détails QSL (date du QSL, locator, état, comté) deviennent optionnels et désactivés par défaut — LoTW met environ dix fois plus longtemps à construire ce rapport, vingt minutes contre deux sur le même compte, et marquer une confirmation n'en a pas besoin. Toujours demandés automatiquement quand on ajoute les QSO absents du log.",
|
||||
"Carte des bandes : l'infobulle indique aussi un nouveau préfixe ou un nouveau locator. Ils restent hors de la bande de couleur de 22 pixels, mais les omettre du texte donnait l'impression que les deux panneaux se contredisaient — le cluster annonçait NOUVEAU PFX pour un spot que la carte disait contacté, et les deux avaient raison. Le « Contacté » de la carte dit aussi de qui il parle : c'est l'ENTITÉ qui a été contactée sur cette bande et ce mode, pas l'indicatif — d'où l'impression de contradiction.",
|
||||
"TCI (SunSDR) : cliquer un spot du cluster ne demande plus un second clic pour obtenir le bon mode — la bande latérale était choisie d'après la fréquence encore annoncée par la radio au lieu de celle qu'on venait de demander. Le journal indique aussi la version d'ExpertSDR, et signale si elle est antérieure à la 1.5 qu'exigent les spots sur le panorama.",
|
||||
"Deux écrans : OpsLog ne s'échappe plus du bureau. Sur un écran placé à gauche de l'écran principal, la position enregistrée était ajoutée à l'origine de cet écran à chaque lancement, si bien que la fenêtre s'éloignait d'un écran à chaque fois jusqu'à devenir invisible."
|
||||
"Deux écrans : OpsLog ne s'échappe plus du bureau. Sur un écran placé à gauche de l'écran principal, la position enregistrée était ajoutée à l'origine de cet écran à chaque lancement, si bien que la fenêtre s'éloignait d'un écran à chaque fois jusqu'à devenir invisible.",
|
||||
"CW en TCI : un SunSDR peut désormais être manipulé par son propre keyer à macros — choisissez TCI comme moteur (Réglages → Manipulateur CW) et les macros, l'appel automatique et le réglage de vitesse passent par la liaison déjà ouverte, sans WinKeyer ni second port série. PAS ENCORE TESTÉ sur l'air."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+11
-3
@@ -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() {
|
||||
|
||||
@@ -4867,7 +4867,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<SelectItem value="yaesu">{t('wk.engYaesu')}</SelectItem>
|
||||
<SelectItem value="kenwood">{t('wk.engKenwood')}</SelectItem>
|
||||
<SelectItem value="flex">{t('wk.engFlex')}</SelectItem>
|
||||
<SelectItem value="tci" disabled>{t('wk.engTci')}</SelectItem>
|
||||
<SelectItem value="tci">{t('wk.engTci')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -4928,6 +4928,22 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : wk.engine === 'tci' ? (
|
||||
<>
|
||||
{(!catCfg.enabled || catCfg.backend !== 'tci') && (
|
||||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||
<span aria-hidden>⚠</span>
|
||||
<span>{t('wk.catWarnTci', { backend: catCfg.enabled ? (catCfg.backend || 'none') : 'disabled' })}</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground -mt-1">{t('wk.tciHint')}</p>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('wk.speed')}</Label>
|
||||
<Input type="number" min={5} max={60} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : wk.engine === 'flex' ? (
|
||||
<>
|
||||
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||||
|
||||
@@ -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' | 'yaesu' | 'kenwood'; // CW output engine (chosen in Settings → CW Keyer)
|
||||
source: 'winkeyer' | 'icom' | 'flex' | 'yaesu' | 'kenwood' | 'tci'; // 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;
|
||||
@@ -109,16 +109,16 @@ export function WinkeyerPanel({
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
{/* CW output engine (chosen in Settings → CW Keyer). */}
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : 'WinKeyer'}
|
||||
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : source === 'yaesu' ? 'Yaesu CW' : source === 'kenwood' ? 'Kenwood CW' : source === 'tci' ? 'TCI CW' : 'WinKeyer'}
|
||||
</span>
|
||||
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
|
||||
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
|
||||
<div className="flex-1" />
|
||||
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' ? (
|
||||
{source === 'icom' || source === 'flex' || source === 'yaesu' || source === 'kenwood' || source === 'tci' ? (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{source === 'flex'
|
||||
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
|
||||
: source === 'yaesu' || source === 'kenwood'
|
||||
: source === 'yaesu' || source === 'kenwood' || source === 'tci'
|
||||
? (connected ? t('wkp.rigReady') : t('wkp.rigOffline'))
|
||||
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
|
||||
</span>
|
||||
|
||||
@@ -128,6 +128,8 @@ const en: Dict = {
|
||||
'mx.tipDxConf': 'Entity confirmed (other callsign)', 'mx.tipDxWork': 'Entity worked (other callsign)',
|
||||
'mx.tipNone': 'Never worked', 'mx.tipClick': 'click to list the QSOs',
|
||||
'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.',
|
||||
'wk.catWarnTci': 'The CW keyer is set to the radio, but the CAT backend is "{backend}". Pick TCI in Settings → CAT, or another keying engine.',
|
||||
'wk.tciHint': "Keying goes through the radio's own macro keyer, over the link already open — no WinKeyer and no second serial port. The radio can stop a message but cannot un-type one, so there is no type-ahead correction here.",
|
||||
'gen.miles': 'Distances in miles', 'gen.milesHint': '(instead of kilometres)',
|
||||
'qslm.qrzTitle': 'Open this callsign on QRZ.com',
|
||||
'qslm.lotwDetail': 'QSL details',
|
||||
@@ -626,6 +628,8 @@ const fr: Dict = {
|
||||
'mx.tipDxConf': 'Entité confirmée (autre indicatif)', 'mx.tipDxWork': 'Entité contactée (autre indicatif)',
|
||||
'mx.tipNone': 'Jamais contacté', 'mx.tipClick': 'cliquer pour lister les QSO',
|
||||
'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.",
|
||||
'wk.catWarnTci': "Le manipulateur CW est réglé sur la radio, mais le backend CAT est « {backend} ». Choisissez TCI dans Réglages → CAT, ou un autre moteur de manipulation.",
|
||||
'wk.tciHint': "La manipulation passe par le keyer à macros de la radio, sur la liaison déjà ouverte — ni WinKeyer ni second port série. La radio sait interrompre un message mais pas en effacer la fin, donc pas de correction en frappe anticipée ici.",
|
||||
'gen.miles': 'Distances en miles', 'gen.milesHint': '(au lieu des kilomètres)',
|
||||
'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com',
|
||||
'qslm.lotwDetail': 'Détails QSL',
|
||||
|
||||
Vendored
+6
@@ -1279,6 +1279,12 @@ export function SyncFolderNow():Promise<number>;
|
||||
|
||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||
|
||||
export function TCISendCW(arg1:string):Promise<void>;
|
||||
|
||||
export function TCISetKeySpeed(arg1:number):Promise<void>;
|
||||
|
||||
export function TCIStopCW():Promise<void>;
|
||||
|
||||
export function TailLogFile(arg1:number):Promise<string>;
|
||||
|
||||
export function TestCloudlogUpload():Promise<string>;
|
||||
|
||||
@@ -2498,6 +2498,18 @@ export function SyncPOTAHunterLog(arg1, arg2) {
|
||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function TCISendCW(arg1) {
|
||||
return window['go']['main']['App']['TCISendCW'](arg1);
|
||||
}
|
||||
|
||||
export function TCISetKeySpeed(arg1) {
|
||||
return window['go']['main']['App']['TCISetKeySpeed'](arg1);
|
||||
}
|
||||
|
||||
export function TCIStopCW() {
|
||||
return window['go']['main']['App']['TCIStopCW']();
|
||||
}
|
||||
|
||||
export function TailLogFile(arg1) {
|
||||
return window['go']['main']['App']['TailLogFile'](arg1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CW keying over TCI — a sixth CW engine, so a SunSDR needs no WinKeyer and no
|
||||
// second serial port: the radio's own macro keyer is driven over the WebSocket
|
||||
// that already carries the CAT.
|
||||
//
|
||||
// The commands, from the TCI command table (confirmed against ars-ka0s/eesdr-tci,
|
||||
// the same source that settled SPOT):
|
||||
//
|
||||
// CW_MACROS:<trx>,<text>; send text through the radio's keyer
|
||||
// CW_MACROS_SPEED:<wpm>; the speed those macros are keyed at
|
||||
// CW_MACROS_STOP; abort what is being keyed
|
||||
// CW_MACROS_EMPTY; the radio saying the buffer has run dry
|
||||
//
|
||||
// CW_MSG (TCI 2.0) does the same with separate before/after callsign fields.
|
||||
// CW_MACROS is used instead because it exists from 1.6 and OpsLog resolves the
|
||||
// variables itself — the text handed here is already what should go on the air.
|
||||
//
|
||||
// Notably absent: there is no backspace. The FlexRadio CWX keyer can un-type
|
||||
// what has not been sent yet; TCI can only stop. So the type-ahead correction
|
||||
// the Flex engine offers is not offered here rather than faked.
|
||||
|
||||
// tciCWTextLimit caps one macro. A runaway paste down a WebSocket that also
|
||||
// carries audio is worth refusing, and no real CW message is this long.
|
||||
const tciCWTextLimit = 512
|
||||
|
||||
// SendCW keys a message through the radio's macro keyer.
|
||||
func (t *TCI) SendCW(text string) error {
|
||||
msg := sanitiseTCICW(text)
|
||||
if msg == "" {
|
||||
return nil
|
||||
}
|
||||
return t.send(fmt.Sprintf("cw_macros:0,%s;", msg))
|
||||
}
|
||||
|
||||
// StopCW aborts the message being keyed.
|
||||
func (t *TCI) StopCW() error { return t.send("cw_macros_stop;") }
|
||||
|
||||
// SetCWSpeed sets the macro keyer speed in words per minute.
|
||||
//
|
||||
// Only the MACRO speed: the paddle keyer has its own (CW_KEYER_SPEED) and an
|
||||
// operator who has set their paddle to 28 wpm did not ask the logger to change
|
||||
// it because a macro went out at 25.
|
||||
func (t *TCI) SetCWSpeed(wpm int) error {
|
||||
if wpm < 5 {
|
||||
wpm = 5
|
||||
}
|
||||
if wpm > 60 {
|
||||
wpm = 60
|
||||
}
|
||||
return t.send(fmt.Sprintf("cw_macros_speed:%d;", wpm))
|
||||
}
|
||||
|
||||
// sanitiseTCICW makes a message safe to put in a TCI command.
|
||||
//
|
||||
// Commas and semicolons are the protocol's own separators — a comma inside the
|
||||
// text would be read as another argument and a semicolon would end the command
|
||||
// early, keying half a message and leaving the rest to be parsed as a command of
|
||||
// its own. Neither belongs in Morse anyway.
|
||||
func sanitiseTCICW(text string) string {
|
||||
s := strings.ToUpper(strings.TrimSpace(text))
|
||||
s = strings.NewReplacer(",", " ", ";", " ", "\r", " ", "\n", " ").Replace(s)
|
||||
if len(s) > tciCWTextLimit {
|
||||
s = s[:tciCWTextLimit]
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -96,3 +96,26 @@ func (m *Manager) TCIPanelDo(fn func(TCIPanelController) error) error {
|
||||
return fn(tc)
|
||||
})
|
||||
}
|
||||
|
||||
// TCICWController is the radio's CW keyer, as the CW engine uses it.
|
||||
//
|
||||
// Three methods, and no backspace: TCI can stop a message but cannot un-type one
|
||||
// (see tci_cw.go). Kept as its own interface rather than folded into the console
|
||||
// one so a CW engine does not have to depend on forty panel setters to key a
|
||||
// message.
|
||||
type TCICWController interface {
|
||||
SendCW(text string) error
|
||||
StopCW() error
|
||||
SetCWSpeed(wpm int) error
|
||||
}
|
||||
|
||||
// TCICWDo dispatches one keyer command onto the CAT goroutine.
|
||||
func (m *Manager) TCICWDo(fn func(TCICWController) error) error {
|
||||
return m.exec(func(b Backend) error {
|
||||
tc, ok := b.(TCICWController)
|
||||
if !ok {
|
||||
return fmt.Errorf("the active CAT backend is not a TCI radio")
|
||||
}
|
||||
return fn(tc)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,3 +22,20 @@ func TestTCISpotsUnsupported(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitiseTCICW(t *testing.T) {
|
||||
// The separators must never survive: a comma would become another argument
|
||||
// and a semicolon would end the command with the message half sent.
|
||||
for _, c := range []struct{ in, want string }{
|
||||
{"cq cq de f4bpo", "CQ CQ DE F4BPO"},
|
||||
{" tu 599 ", "TU 599"},
|
||||
{"73, gl", "73 GL"},
|
||||
{"test;cw_macros_stop", "TEST CW_MACROS_STOP"},
|
||||
{"line\r\nbreak", "LINE BREAK"},
|
||||
{" ", ""},
|
||||
} {
|
||||
if got := sanitiseTCICW(c.in); got != c.want {
|
||||
t.Errorf("sanitiseTCICW(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user