From 6db90abcad2f85efbdf344cfe17a0ddeb4a40e91 Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 7 Jul 2026 19:35:25 +0200 Subject: [PATCH] feat: Management of multiple slices on Flexradio --- app.go | 16 ++ frontend/src/components/FlexPanel.tsx | 35 +++- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/main/App.d.ts | 4 + frontend/wailsjs/go/main/App.js | 8 + frontend/wailsjs/go/models.ts | 26 +++ internal/cat/cat.go | 17 ++ internal/cat/flex.go | 269 ++++++++++++++++---------- 8 files changed, 276 insertions(+), 103 deletions(-) diff --git a/app.go b/app.go index 84037f5..755ac9d 100644 --- a/app.go +++ b/app.go @@ -8270,6 +8270,22 @@ func (a *App) FlexSetSplit(on bool) error { 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). const keyFlexBandAnt = "flex.band_antennas" diff --git a/frontend/src/components/FlexPanel.tsx b/frontend/src/components/FlexPanel.tsx index e03015a..1512caa 100644 --- a/frontend/src/components/FlexPanel.tsx +++ b/frontend/src/components/FlexPanel.tsx @@ -5,7 +5,7 @@ import { FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic, FlexMox, FlexAmpOperate, GetPGXLStatus, PGXLSetFanMode, - FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, + FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, @@ -30,8 +30,10 @@ type FlexState = { apf: boolean; apf_level: number; filter_lo: number; filter_hi: number; amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string; 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 }; const ZERO: FlexState = { @@ -280,6 +282,37 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number + {/* 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 && ( +
+ {st.slices.map((sl) => ( + + ))} +
+ )} + {off && (
{t('flxp.waiting')} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index ba048f3..eec41ea 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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}', '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 F1–F6.', '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.', - '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', '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', @@ -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}', '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 F1–F6.', '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.', - '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', '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', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 77cda9d..35c68c7 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -172,6 +172,8 @@ export function FlexSetAPFLevel(arg1:number):Promise; export function FlexSetATUMemories(arg1:boolean):Promise; +export function FlexSetActiveSlice(arg1:number):Promise; + export function FlexSetAudioLevel(arg1:number):Promise; export function FlexSetCWBreakInDelay(arg1:number):Promise; @@ -216,6 +218,8 @@ export function FlexSetSplit(arg1:boolean):Promise; export function FlexSetTXAntenna(arg1:string):Promise; +export function FlexSetTXSlice(arg1:number):Promise; + export function FlexSetTunePower(arg1:number):Promise; export function FlexSetVox(arg1:boolean):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 93bdceb..abf20e8 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -306,6 +306,10 @@ export function FlexSetATUMemories(arg1) { return window['go']['main']['App']['FlexSetATUMemories'](arg1); } +export function FlexSetActiveSlice(arg1) { + return window['go']['main']['App']['FlexSetActiveSlice'](arg1); +} + export function FlexSetAudioLevel(arg1) { return window['go']['main']['App']['FlexSetAudioLevel'](arg1); } @@ -394,6 +398,10 @@ export function FlexSetTXAntenna(arg1) { return window['go']['main']['App']['FlexSetTXAntenna'](arg1); } +export function FlexSetTXSlice(arg1) { + return window['go']['main']['App']['FlexSetTXSlice'](arg1); +} + export function FlexSetTunePower(arg1) { return window['go']['main']['App']['FlexSetTunePower'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 59c6aa8..716eb17 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -541,9 +541,34 @@ export namespace cat { 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 { available: boolean; model?: string; + slices?: FlexSliceInfo[]; rf_power: number; tune_power: number; tune: boolean; @@ -600,6 +625,7 @@ export namespace cat { if ('string' === typeof source) source = JSON.parse(source); this.available = source["available"]; this.model = source["model"]; + this.slices = this.convertValues(source["slices"], FlexSliceInfo); this.rf_power = source["rf_power"]; this.tune_power = source["tune_power"]; this.tune = source["tune"]; diff --git a/internal/cat/cat.go b/internal/cat/cat.go index e380643..9cec63c 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -245,9 +245,24 @@ func (m *Manager) SendSpot(s SpotInfo) { // FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated // 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.) +// 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 { Available bool `json:"available"` // backend is Flex and handshaked 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"` TunePower int `json:"tune_power"` Tune bool `json:"tune"` // tune carrier active @@ -339,6 +354,8 @@ type FlexController interface { SetMute(bool) error SetRXAntenna(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 SetNB(bool) error SetNBLevel(int) error diff --git a/internal/cat/flex.go b/internal/cat/flex.go index 9b55eae..26a7c10 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -774,21 +774,19 @@ func (f *Flex) ReadState() (RigState, error) { if !f.gotHandle { return st, nil // connected TCP but radio hasn't handshaked yet } - rx, tx := f.pickSlicesLocked() - if rx == nil && tx == nil { + main, rxS, txSplit := f.operatingLocked() + if main == nil { return st, nil } - if tx == nil { - tx = rx - } - if rx == nil { - rx = tx - } - st.FreqHz = tx.freqHz - st.Mode = flexModeToADIF(tx.mode) - if rx.freqHz != tx.freqHz { + // Main frequency/mode = the ACTIVE slice (what the operator is on). Only a + // genuine same-band split adds a separate TX freq; then ADIF convention wants + // FreqHz = TX and RxFreqHz = RX. + st.FreqHz = main.freqHz + st.Mode = flexModeToADIF(main.mode) + if rxS != nil && txSplit != nil { 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) if sig != f.lastStateSig { @@ -798,76 +796,130 @@ func (f *Flex) ReadState() (RigState, error) { return st, nil } -// pickSlicesLocked chooses the TX and RX slices among in-use slices. TX is the -// slice flagged tx=1. RX is the slice you actually receive on — the NON-TX slice -// (preferring the active/focused one), NOT simply the active slice: tuning the -// TX slice makes it the active/focused slice, which would otherwise collapse RX -// onto TX and hide the split. Caller holds f.mu. -func (f *Flex) pickSlicesLocked() (rx, tx *flexSlice) { - idxs := make([]int, 0, len(f.slices)) - for i, s := range f.slices { - 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 +// mainSliceLocked is the operator's slice: the ACTIVE (focused) in-use slice, or +// the lowest-indexed in-use slice when none is flagged active. EVERYTHING the +// user does — freq/mode display, RX DSP, tuning, mode changes, spot clicks — +// follows this slice, so a second independent slice (e.g. monitoring another +// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in +// use. Caller holds f.mu. +func (f *Flex) mainSliceLocked() (int, *flexSlice) { + best, bestS := 1<<30, (*flexSlice)(nil) for idx, s := range f.slices { if !s.inUse { continue } if s.active { - return idx + return idx, s } if idx < best { - best, found = idx, true + best, bestS = idx, s } } - if found { - return best + if bestS != nil { + 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 } +// 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 { if hz <= 0 { return fmt.Errorf("flex: invalid frequency") @@ -1048,19 +1100,10 @@ func clampLevel(v int) int { return v } -// rxSliceLocked returns the active RX slice and its index (-1 when none), using -// the same RX-selection rule as ReadState. Caller holds f.mu. +// rxSliceLocked returns the operator's (main/active) slice and its index — the +// slice every RX-DSP control and read targets. Caller holds f.mu. func (f *Flex) rxSliceLocked() (int, *flexSlice) { - rx, _ := f.pickSlicesLocked() - if rx == nil { - return -1, nil - } - for i, s := range f.slices { - if s == rx { - return i, rx - } - } - return -1, rx + return f.mainSliceLocked() } // 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, 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.RXFreqHz = rx.freqHz - st.TXFreqHz = tx.freqHz + st.RXFreqHz = rxS.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 { st.RXAvail = true @@ -1229,35 +1293,40 @@ func (f *Flex) SetSplit(on bool) error { f.mu.Unlock() 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 for i, s := range f.slices { - if s == rx { + if s == rxS { rxIdx = i } - if s == tx { + if s == txS { txIdx = i } } - alreadySplit := rx != nil && tx != nil && rx != tx - var rxFreq int64 - var rxMode, rxAnt string - if rx != nil { - rxFreq, rxMode, rxAnt = rx.freqHz, rx.mode, rx.rxAnt + alreadySplit := rxS != nil && txS != nil + var baseFreq int64 + var baseMode, baseAnt string + if main != nil { + baseFreq, baseMode, baseAnt = main.freqHz, main.mode, main.rxAnt } f.mu.Unlock() if on { - if alreadySplit || rx == nil { - return nil // already split (or no slice) + if alreadySplit || main == nil { + return nil // already split, or no active slice } offset := int64(5000) - if strings.HasPrefix(strings.ToUpper(rxMode), "CW") { + if strings.HasPrefix(strings.ToUpper(baseMode), "CW") { offset = 1000 } - cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(rxFreq+offset)/1e6, rxMode) - if rxAnt != "" { - cmd += " ant=" + rxAnt + // Create the TX slice at the ACTIVE slice's freq + offset (same band). + cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(baseFreq+offset)/1e6, baseMode) + if baseAnt != "" { + cmd += " ant=" + baseAnt } if seq := f.send(cmd); seq > 0 { f.mu.Lock() @@ -1270,10 +1339,10 @@ func (f *Flex) SetSplit(on bool) error { return nil } 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 { - 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 }