feat: Management of multiple slices on Flexradio

This commit is contained in:
2026-07-07 19:35:25 +02:00
parent 6b9307b427
commit 6db90abcad
8 changed files with 276 additions and 103 deletions
+16
View File
@@ -8270,6 +8270,22 @@ func (a *App) FlexSetSplit(on bool) error {
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSplit(on) }) return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSplit(on) })
} }
// FlexSetActiveSlice focuses a slice (A/B/C/D…) so all commands target it.
func (a *App) FlexSetActiveSlice(idx int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetActiveSlice(idx) })
}
// FlexSetTXSlice makes a slice the transmitter (e.g. TX on the active slice).
func (a *App) FlexSetTXSlice(idx int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXSlice(idx) })
}
// keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local). // keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local).
const keyFlexBandAnt = "flex.band_antennas" const keyFlexBandAnt = "flex.band_antennas"
+34 -1
View File
@@ -5,7 +5,7 @@ import {
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
FlexMox, FlexAmpOperate, FlexMox, FlexAmpOperate,
GetPGXLStatus, PGXLSetFanMode, GetPGXLStatus, PGXLSetFanMode,
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
@@ -30,8 +30,10 @@ type FlexState = {
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number; apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string; amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
meters?: Meter[]; meters?: Meter[];
slices?: FlexSlice[];
}; };
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number }; type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
const ZERO: FlexState = { const ZERO: FlexState = {
@@ -280,6 +282,37 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
</span> </span>
</div> </div>
{/* Slices A/B/C/D — every in-use receiver. Click one to make it the
ACTIVE slice: the main frequency, mode, DSP and spot-clicks all follow
it. The active slice is highlighted; the TX slice is flagged. */}
{!!st.slices && st.slices.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{st.slices.map((sl) => (
<button key={sl.index} type="button" disabled={off}
onClick={() => FlexSetActiveSlice(sl.index).catch(() => {})}
title={t('flxp.sliceHint')}
className={cn('flex items-center gap-2 rounded-lg border px-3 py-1.5 transition-colors disabled:opacity-40',
sl.active ? 'border-info bg-info/10 ring-1 ring-info' : 'border-border bg-card hover:bg-muted')}>
<span className={cn('inline-flex size-6 items-center justify-center rounded-md text-sm font-extrabold',
sl.active ? 'bg-info text-info-foreground' : 'bg-muted text-muted-foreground')}>{sl.letter}</span>
<span className="font-mono font-bold tabular-nums text-sm">{(sl.freq_hz / 1e6).toFixed(3)}</span>
<span className="text-[11px] text-muted-foreground uppercase">{sl.mode}</span>
{sl.band ? <span className="text-[10px] text-muted-foreground">{sl.band}</span> : null}
{/* TX badge — click a non-TX slice's badge to move TX onto it
(e.g. transmit on the active slice). stopPropagation so it
doesn't also fire the outer "make active" click. */}
<span role="button" tabIndex={-1}
onClick={(e) => { e.stopPropagation(); if (!sl.tx && !off) FlexSetTXSlice(sl.index).catch(() => {}); }}
title={sl.tx ? t('flxp.txSlice') : t('flxp.setTxSlice')}
className={cn('rounded text-[10px] font-bold px-1',
sl.tx ? 'bg-danger text-danger-foreground' : 'border border-danger/50 text-danger cursor-pointer hover:bg-danger/10')}>
TX
</span>
</button>
))}
</div>
)}
{off && ( {off && (
<div className="text-center text-sm text-muted-foreground py-6"> <div className="text-center text-sm text-muted-foreground py-6">
{t('flxp.waiting')} {t('flxp.waiting')}
+2 -2
View File
@@ -191,7 +191,7 @@ const en: Dict = {
'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}', '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}',
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.',
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay', 'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope 50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?', 'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope 50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
'rst.clickToFill': 'Click to set RST tx from the signal', 'rst.clickToFill': 'Click to set RST tx from the signal',
@@ -377,7 +377,7 @@ const fr: Dict = {
'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}', '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}',
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.',
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai', 'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope 50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?', 'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope 50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal', 'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
+4
View File
@@ -172,6 +172,8 @@ export function FlexSetAPFLevel(arg1:number):Promise<void>;
export function FlexSetATUMemories(arg1:boolean):Promise<void>; export function FlexSetATUMemories(arg1:boolean):Promise<void>;
export function FlexSetActiveSlice(arg1:number):Promise<void>;
export function FlexSetAudioLevel(arg1:number):Promise<void>; export function FlexSetAudioLevel(arg1:number):Promise<void>;
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>; export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
@@ -216,6 +218,8 @@ export function FlexSetSplit(arg1:boolean):Promise<void>;
export function FlexSetTXAntenna(arg1:string):Promise<void>; export function FlexSetTXAntenna(arg1:string):Promise<void>;
export function FlexSetTXSlice(arg1:number):Promise<void>;
export function FlexSetTunePower(arg1:number):Promise<void>; export function FlexSetTunePower(arg1:number):Promise<void>;
export function FlexSetVox(arg1:boolean):Promise<void>; export function FlexSetVox(arg1:boolean):Promise<void>;
+8
View File
@@ -306,6 +306,10 @@ export function FlexSetATUMemories(arg1) {
return window['go']['main']['App']['FlexSetATUMemories'](arg1); return window['go']['main']['App']['FlexSetATUMemories'](arg1);
} }
export function FlexSetActiveSlice(arg1) {
return window['go']['main']['App']['FlexSetActiveSlice'](arg1);
}
export function FlexSetAudioLevel(arg1) { export function FlexSetAudioLevel(arg1) {
return window['go']['main']['App']['FlexSetAudioLevel'](arg1); return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
} }
@@ -394,6 +398,10 @@ export function FlexSetTXAntenna(arg1) {
return window['go']['main']['App']['FlexSetTXAntenna'](arg1); return window['go']['main']['App']['FlexSetTXAntenna'](arg1);
} }
export function FlexSetTXSlice(arg1) {
return window['go']['main']['App']['FlexSetTXSlice'](arg1);
}
export function FlexSetTunePower(arg1) { export function FlexSetTunePower(arg1) {
return window['go']['main']['App']['FlexSetTunePower'](arg1); return window['go']['main']['App']['FlexSetTunePower'](arg1);
} }
+26
View File
@@ -541,9 +541,34 @@ export namespace cat {
this.callsign = source["callsign"]; this.callsign = source["callsign"];
} }
} }
export class FlexSliceInfo {
index: number;
letter: string;
freq_hz: number;
mode?: string;
band?: string;
active: boolean;
tx: boolean;
static createFrom(source: any = {}) {
return new FlexSliceInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.index = source["index"];
this.letter = source["letter"];
this.freq_hz = source["freq_hz"];
this.mode = source["mode"];
this.band = source["band"];
this.active = source["active"];
this.tx = source["tx"];
}
}
export class FlexTXState { export class FlexTXState {
available: boolean; available: boolean;
model?: string; model?: string;
slices?: FlexSliceInfo[];
rf_power: number; rf_power: number;
tune_power: number; tune_power: number;
tune: boolean; tune: boolean;
@@ -600,6 +625,7 @@ export namespace cat {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.available = source["available"]; this.available = source["available"];
this.model = source["model"]; this.model = source["model"];
this.slices = this.convertValues(source["slices"], FlexSliceInfo);
this.rf_power = source["rf_power"]; this.rf_power = source["rf_power"];
this.tune_power = source["tune_power"]; this.tune_power = source["tune_power"];
this.tune = source["tune"]; this.tune = source["tune"];
+17
View File
@@ -245,9 +245,24 @@ func (m *Manager) SendSpot(s SpotInfo) {
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated // FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by // FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
// the radio over TCP; live meters arrive over a separate UDP stream later.) // the radio over TCP; live meters arrive over a separate UDP stream later.)
// FlexSliceInfo identifies one FlexRadio receiver slice (A/B/C/D…) for the
// panel, so the operator sees every slice and which one is active/TX.
type FlexSliceInfo struct {
Index int `json:"index"` // 0-based slice index
Letter string `json:"letter"` // A, B, C, D…
FreqHz int64 `json:"freq_hz"`
Mode string `json:"mode,omitempty"` // ADIF mode
Band string `json:"band,omitempty"`
Active bool `json:"active"` // the focused/operating slice
TX bool `json:"tx"` // this slice transmits
}
type FlexTXState struct { type FlexTXState struct {
Available bool `json:"available"` // backend is Flex and handshaked Available bool `json:"available"` // backend is Flex and handshaked
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
// Slices lists every in-use receiver slice (A/B/C/D…) so the panel can show
// them all and highlight the active one. The active slice drives everything.
Slices []FlexSliceInfo `json:"slices,omitempty"`
RFPower int `json:"rf_power"` RFPower int `json:"rf_power"`
TunePower int `json:"tune_power"` TunePower int `json:"tune_power"`
Tune bool `json:"tune"` // tune carrier active Tune bool `json:"tune"` // tune carrier active
@@ -339,6 +354,8 @@ type FlexController interface {
SetMute(bool) error SetMute(bool) error
SetRXAntenna(string) error SetRXAntenna(string) error
SetTXAntenna(string) error SetTXAntenna(string) error
SetActiveSlice(int) error // focus slice idx so commands target it
SetTXSlice(int) error // make slice idx the transmitter (tx=1)
SetSplit(bool) error SetSplit(bool) error
SetNB(bool) error SetNB(bool) error
SetNBLevel(int) error SetNBLevel(int) error
+169 -100
View File
@@ -774,21 +774,19 @@ func (f *Flex) ReadState() (RigState, error) {
if !f.gotHandle { if !f.gotHandle {
return st, nil // connected TCP but radio hasn't handshaked yet return st, nil // connected TCP but radio hasn't handshaked yet
} }
rx, tx := f.pickSlicesLocked() main, rxS, txSplit := f.operatingLocked()
if rx == nil && tx == nil { if main == nil {
return st, nil return st, nil
} }
if tx == nil { // Main frequency/mode = the ACTIVE slice (what the operator is on). Only a
tx = rx // genuine same-band split adds a separate TX freq; then ADIF convention wants
} // FreqHz = TX and RxFreqHz = RX.
if rx == nil { st.FreqHz = main.freqHz
rx = tx st.Mode = flexModeToADIF(main.mode)
} if rxS != nil && txSplit != nil {
st.FreqHz = tx.freqHz
st.Mode = flexModeToADIF(tx.mode)
if rx.freqHz != tx.freqHz {
st.Split = true st.Split = true
st.RxFreqHz = rx.freqHz st.RxFreqHz = rxS.freqHz
st.FreqHz = txSplit.freqHz
} }
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode) sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
if sig != f.lastStateSig { if sig != f.lastStateSig {
@@ -798,76 +796,130 @@ func (f *Flex) ReadState() (RigState, error) {
return st, nil return st, nil
} }
// pickSlicesLocked chooses the TX and RX slices among in-use slices. TX is the // mainSliceLocked is the operator's slice: the ACTIVE (focused) in-use slice, or
// slice flagged tx=1. RX is the slice you actually receive on — the NON-TX slice // the lowest-indexed in-use slice when none is flagged active. EVERYTHING the
// (preferring the active/focused one), NOT simply the active slice: tuning the // user does — freq/mode display, RX DSP, tuning, mode changes, spot clicks —
// TX slice makes it the active/focused slice, which would otherwise collapse RX // follows this slice, so a second independent slice (e.g. monitoring another
// onto TX and hide the split. Caller holds f.mu. // band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
func (f *Flex) pickSlicesLocked() (rx, tx *flexSlice) { // use. Caller holds f.mu.
idxs := make([]int, 0, len(f.slices)) func (f *Flex) mainSliceLocked() (int, *flexSlice) {
for i, s := range f.slices { best, bestS := 1<<30, (*flexSlice)(nil)
if s.inUse {
idxs = append(idxs, i)
}
}
sort.Ints(idxs)
var active, txS, nonTx, first *flexSlice
for _, i := range idxs {
s := f.slices[i]
if first == nil {
first = s
}
if s.active {
active = s
}
if s.tx {
txS = s
} else if nonTx == nil {
nonTx = s
}
}
tx = txS
if tx == nil {
if active != nil {
tx = active
} else {
tx = first
}
}
// RX = the receive slice: the active one if it isn't the TX slice, else the
// first non-TX slice; fall back to TX (simplex) when there's only one slice.
switch {
case active != nil && active != tx:
rx = active
case nonTx != nil:
rx = nonTx
default:
rx = tx
}
return rx, tx
}
// activeSliceIndexLocked returns the slice index to send commands to (the active
// slice, else the lowest in-use index, else 0). Caller holds f.mu.
func (f *Flex) activeSliceIndexLocked() int {
best, found := 1<<30, false
for idx, s := range f.slices { for idx, s := range f.slices {
if !s.inUse { if !s.inUse {
continue continue
} }
if s.active { if s.active {
return idx return idx, s
} }
if idx < best { if idx < best {
best, found = idx, true best, bestS = idx, s
} }
} }
if found { if bestS != nil {
return best return best, bestS
}
return -1, nil
}
// activeSliceIndexLocked returns the slice index to send commands to (the main
// slice, else 0). Caller holds f.mu.
func (f *Flex) activeSliceIndexLocked() int {
if idx, _ := f.mainSliceLocked(); idx >= 0 {
return idx
} }
return 0 return 0
} }
// sliceLetter maps a slice index to its SmartSDR letter (0→A, 1→B, …).
func sliceLetter(idx int) string {
if idx < 0 || idx > 25 {
return fmt.Sprintf("%d", idx)
}
return string(rune('A' + idx))
}
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
// Caller holds f.mu.
func (f *Flex) txSliceLocked() *flexSlice {
for _, s := range f.slices {
if s.inUse && s.tx {
return s
}
}
return nil
}
// operatingLocked resolves the operator's slices: the MAIN (active) slice for the
// mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is
// the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different
// freq) — detected from the pair itself, NOT from which slice is active (the TX
// slice often steals focus right after "slice create", which must NOT read as
// "no split"). A slice on another band is an independent receiver, ignored.
// Caller holds f.mu.
func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
_, main = f.mainSliceLocked()
txS := f.txSliceLocked()
if txS == nil {
return main, nil, nil
}
bt := BandFromHz(txS.freqHz)
if bt == "" {
return main, nil, nil
}
// RX = the active slice when it's a distinct same-band slice, else the first
// other in-use same-band slice.
if main != nil && main != txS && main.freqHz != txS.freqHz && BandFromHz(main.freqHz) == bt {
rx = main
} else {
for _, s := range f.slices {
if s.inUse && s != txS && s.freqHz != txS.freqHz && BandFromHz(s.freqHz) == bt {
rx = s
break
}
}
}
if rx == nil {
return main, nil, nil // tx slice alone (simplex) → not split
}
return main, rx, txS
}
// SetActiveSlice focuses slice idx on the radio so every subsequent command
// (freq / mode / DSP / spot click) targets it. Lets the operator pick the
// operating slice from OpsLog (like SliceLogger's A/B selector).
func (f *Flex) SetActiveSlice(idx int) error {
f.mu.Lock()
_, exists := f.slices[idx]
connected := f.conn != nil
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if !exists {
return fmt.Errorf("flex: no slice %d", idx)
}
f.send(fmt.Sprintf("slice s %d active=1", idx))
return nil
}
// SetTXSlice makes slice idx the transmitter (tx=1) — e.g. "put TX on the active
// slice" so you transmit where you're listening. Only one slice can be TX; the
// radio clears the flag on the others.
func (f *Flex) SetTXSlice(idx int) error {
f.mu.Lock()
_, exists := f.slices[idx]
connected := f.conn != nil
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if !exists {
return fmt.Errorf("flex: no slice %d", idx)
}
f.send(fmt.Sprintf("slice s %d tx=1", idx))
return nil
}
func (f *Flex) SetFrequency(hz int64) error { func (f *Flex) SetFrequency(hz int64) error {
if hz <= 0 { if hz <= 0 {
return fmt.Errorf("flex: invalid frequency") return fmt.Errorf("flex: invalid frequency")
@@ -1048,19 +1100,10 @@ func clampLevel(v int) int {
return v return v
} }
// rxSliceLocked returns the active RX slice and its index (-1 when none), using // rxSliceLocked returns the operator's (main/active) slice and its index — the
// the same RX-selection rule as ReadState. Caller holds f.mu. // slice every RX-DSP control and read targets. Caller holds f.mu.
func (f *Flex) rxSliceLocked() (int, *flexSlice) { func (f *Flex) rxSliceLocked() (int, *flexSlice) {
rx, _ := f.pickSlicesLocked() return f.mainSliceLocked()
if rx == nil {
return -1, nil
}
for i, s := range f.slices {
if s == rx {
return i, rx
}
}
return -1, rx
} }
// FlexState returns a snapshot of the radio's transmit/ATU state plus the active // FlexState returns a snapshot of the radio's transmit/ATU state plus the active
@@ -1093,10 +1136,31 @@ func (f *Flex) FlexState() FlexTXState {
CWSidetone: f.tx.cwSidetone, CWSidetone: f.tx.cwSidetone,
CWMonLevel: f.tx.cwMonLevel, CWMonLevel: f.tx.cwMonLevel,
} }
if rx, tx := f.pickSlicesLocked(); rx != nil && tx != nil && rx != tx && rx.freqHz != tx.freqHz { if _, rxS, txSplit := f.operatingLocked(); rxS != nil && txSplit != nil {
st.Split = true st.Split = true
st.RXFreqHz = rx.freqHz st.RXFreqHz = rxS.freqHz
st.TXFreqHz = tx.freqHz st.TXFreqHz = txSplit.freqHz
}
// Every in-use slice (A/B/C/D…) so the panel shows them all and highlights the
// active/TX one — the active slice drives everything the operator does.
sidx := make([]int, 0, len(f.slices))
for i, s := range f.slices {
if s.inUse {
sidx = append(sidx, i)
}
}
sort.Ints(sidx)
for _, i := range sidx {
s := f.slices[i]
st.Slices = append(st.Slices, FlexSliceInfo{
Index: i,
Letter: sliceLetter(i),
FreqHz: s.freqHz,
Mode: flexModeToADIF(s.mode),
Band: BandFromHz(s.freqHz),
Active: s.active,
TX: s.tx,
})
} }
if _, rx := f.rxSliceLocked(); rx != nil { if _, rx := f.rxSliceLocked(); rx != nil {
st.RXAvail = true st.RXAvail = true
@@ -1229,35 +1293,40 @@ func (f *Flex) SetSplit(on bool) error {
f.mu.Unlock() f.mu.Unlock()
return fmt.Errorf("flex: not connected") return fmt.Errorf("flex: not connected")
} }
rx, tx := f.pickSlicesLocked() // Split is built AROUND THE ACTIVE slice, and "already split" uses the SAME
// same-band-pair rule as the button state (operatingLocked) — otherwise two
// INDEPENDENT slices on different bands look "already split" and SPLIT ON does
// nothing (the bug the user hit: A on 20m + B on 80m).
main, rxS, txS := f.operatingLocked()
rxIdx, txIdx := -1, -1 rxIdx, txIdx := -1, -1
for i, s := range f.slices { for i, s := range f.slices {
if s == rx { if s == rxS {
rxIdx = i rxIdx = i
} }
if s == tx { if s == txS {
txIdx = i txIdx = i
} }
} }
alreadySplit := rx != nil && tx != nil && rx != tx alreadySplit := rxS != nil && txS != nil
var rxFreq int64 var baseFreq int64
var rxMode, rxAnt string var baseMode, baseAnt string
if rx != nil { if main != nil {
rxFreq, rxMode, rxAnt = rx.freqHz, rx.mode, rx.rxAnt baseFreq, baseMode, baseAnt = main.freqHz, main.mode, main.rxAnt
} }
f.mu.Unlock() f.mu.Unlock()
if on { if on {
if alreadySplit || rx == nil { if alreadySplit || main == nil {
return nil // already split (or no slice) return nil // already split, or no active slice
} }
offset := int64(5000) offset := int64(5000)
if strings.HasPrefix(strings.ToUpper(rxMode), "CW") { if strings.HasPrefix(strings.ToUpper(baseMode), "CW") {
offset = 1000 offset = 1000
} }
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(rxFreq+offset)/1e6, rxMode) // Create the TX slice at the ACTIVE slice's freq + offset (same band).
if rxAnt != "" { cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(baseFreq+offset)/1e6, baseMode)
cmd += " ant=" + rxAnt if baseAnt != "" {
cmd += " ant=" + baseAnt
} }
if seq := f.send(cmd); seq > 0 { if seq := f.send(cmd); seq > 0 {
f.mu.Lock() f.mu.Lock()
@@ -1270,10 +1339,10 @@ func (f *Flex) SetSplit(on bool) error {
return nil return nil
} }
if txIdx >= 0 { if txIdx >= 0 {
f.send(fmt.Sprintf("slice remove %d", txIdx)) f.send(fmt.Sprintf("slice remove %d", txIdx)) // drop the extra TX slice
} }
if rxIdx >= 0 { if rxIdx >= 0 {
f.send(fmt.Sprintf("slice s %d tx=1", rxIdx)) f.send(fmt.Sprintf("slice s %d tx=1", rxIdx)) // RX slice transmits again
} }
return nil return nil
} }