feat: Flex stays on the RX slice in split

SmartSDR moves its active flag onto the TX slice as soon as the transmit
frequency is touched, and OpsLog followed it. But the operator is listening to
the DX on the other slice: the S-meter, audio level, filter and DSP they are
adjusting all belong there, so following the transmitter handed them the
controls of a receiver they were not using.

In split the receive slice now wins. Simplex is unchanged, and two same-band
slices in different classes (SSB alongside FT8) are still not a split.

A slice clicked IN OPSLOG is remembered and overrides both the split rule and
the radio's focus — that is the operator speaking. Activating a slice on the
radio or in SmartSDR deliberately does not move OpsLog. The pin is dropped when
that slice stops being in use, so a closed receiver cannot strand the display.

The panel now highlights the slice OpsLog is working with rather than the radio's
focused one; reporting the radio's flag would light up one slice while every
control acted on another.
This commit is contained in:
2026-07-30 23:03:29 +02:00
parent 702ca4f0c9
commit 961357474d
3 changed files with 154 additions and 5 deletions
+10
View File
@@ -1,4 +1,14 @@
[ [
{
"version": "0.22.4",
"date": "",
"en": [
"FlexRadio: in split, OpsLog stays on the receive slice instead of following the transmitter, so the S-meter, audio and DSP are those of the station being worked. Clicking a slice in OpsLog still selects it; selecting one on the radio does not move OpsLog."
],
"fr": [
"FlexRadio : en split, OpsLog reste sur la slice de réception au lieu de suivre l'émission — S-mètre, audio et DSP sont ceux de la station travaillée. Cliquer une slice dans OpsLog la sélectionne toujours ; en choisir une sur la radio ne déplace pas OpsLog."
]
},
{ {
"version": "0.22.3", "version": "0.22.3",
"date": "", "date": "",
+76 -5
View File
@@ -35,7 +35,12 @@ type Flex struct {
model string model string
gotHandle bool gotHandle bool
slices map[int]*flexSlice slices map[int]*flexSlice
// pinnedSlice is the slice the operator chose IN OPSLOG (-1 = none). It
// overrides the radio's own "active" flag, and only an OpsLog click changes
// it — activating a slice on the radio's front panel or in SmartSDR does
// not, by design (see mainSliceLocked).
pinnedSlice int
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab) tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
amp flexAmp // external amplifier (PowerGenius XL) state amp flexAmp // external amplifier (PowerGenius XL) state
micProfiles []string // available mic profiles (SmartSDR "profile mic list") micProfiles []string // available mic profiles (SmartSDR "profile mic list")
@@ -183,6 +188,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{}, spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{}, meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{}, sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
pinnedSlice: -1,
} }
} }
@@ -968,7 +974,17 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
// map order returned a RANDOM active slice each call → the operating frequency // map order returned a RANDOM active slice each call → the operating frequency
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever. // flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
// Deterministic order = the lowest-indexed active slice wins, stably. // Deterministic order = the lowest-indexed active slice wins, stably.
// An explicit choice made in OpsLog wins over everything, including the
// radio's active flag. It is dropped only when that slice stops being in
// use — a slice the operator closed is not a choice any more.
if f.pinnedSlice >= 0 {
if s := f.slices[f.pinnedSlice]; s != nil && s.inUse {
return f.pinnedSlice, s
}
}
firstInUse := -1 firstInUse := -1
chosen := -1
for _, idx := range f.sortedSliceIdxLocked() { for _, idx := range f.sortedSliceIdxLocked() {
s := f.slices[idx] s := f.slices[idx]
if !s.inUse { if !s.inUse {
@@ -978,11 +994,56 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) {
firstInUse = idx firstInUse = idx
} }
if s.active { if s.active {
return idx, s chosen = idx
break
} }
} }
if firstInUse >= 0 { if chosen < 0 {
return firstInUse, f.slices[firstInUse] chosen = firstInUse
}
if chosen < 0 {
return -1, nil
}
// In SPLIT, stay on the RECEIVE slice.
//
// SmartSDR moves its "active" flag to the TX slice as soon as the transmit
// frequency is touched, so OpsLog followed the transmitter. But the operator
// is LISTENING to the DX on the other slice: the S-meter, the audio level,
// the filter and the DSP they are adjusting all belong there, and following
// the TX slice hands them the controls for a receiver they are not using.
//
// Only in split, and only when nothing was pinned above — clicking slice B
// in OpsLog still selects it and keeps it.
if txS := f.txSliceLocked(); txS != nil && f.slices[chosen] == txS {
if rxIdx, rxS := f.splitPartnerLocked(txS); rxS != nil {
return rxIdx, rxS
}
}
return chosen, f.slices[chosen]
}
// splitPartnerLocked returns the slice that FORMS A SPLIT with txS: in use, on
// the same band, at a different frequency, in the same class (phone with phone,
// CW with CW — so SSB alongside FT8 on one band is not a split).
//
// Lowest index first, so the answer is stable; map order is randomised in Go
// and gave a different partner on each poll. Caller holds f.mu.
func (f *Flex) splitPartnerLocked(txS *flexSlice) (int, *flexSlice) {
if txS == nil {
return -1, nil
}
bt := BandFromHz(txS.freqHz)
ct := flexSplitClass(txS.mode)
if bt == "" || ct == "" {
return -1, nil
}
for _, idx := range f.sortedSliceIdxLocked() {
s := f.slices[idx]
if s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct {
return idx, s
}
} }
return -1, nil return -1, nil
} }
@@ -1089,6 +1150,11 @@ func (f *Flex) SetActiveSlice(idx int) error {
if !exists { if !exists {
return fmt.Errorf("flex: no slice %d", idx) return fmt.Errorf("flex: no slice %d", idx)
} }
// Remember it: this is the operator speaking, and it must survive the radio
// moving its own active flag to the transmitter during split.
f.mu.Lock()
f.pinnedSlice = idx
f.mu.Unlock()
f.send(fmt.Sprintf("slice s %d active=1", idx)) f.send(fmt.Sprintf("slice s %d active=1", idx))
return nil return nil
} }
@@ -1389,6 +1455,7 @@ func (f *Flex) FlexState() FlexTXState {
} }
} }
sort.Ints(sidx) sort.Ints(sidx)
mainIdx, _ := f.mainSliceLocked()
for _, i := range sidx { for _, i := range sidx {
s := f.slices[i] s := f.slices[i]
st.Slices = append(st.Slices, FlexSliceInfo{ st.Slices = append(st.Slices, FlexSliceInfo{
@@ -1397,7 +1464,11 @@ func (f *Flex) FlexState() FlexTXState {
FreqHz: s.freqHz, FreqHz: s.freqHz,
Mode: flexModeToADIF(s.mode), Mode: flexModeToADIF(s.mode),
Band: BandFromHz(s.freqHz), Band: BandFromHz(s.freqHz),
Active: s.active, // The slice OpsLog is working with, which is not always the one the
// radio has focused — in split OpsLog stays on RX. Reporting the
// radio's flag here would highlight one slice while every control
// acted on another.
Active: i == mainIdx,
TX: s.tx, TX: s.tx,
}) })
} }
+68
View File
@@ -0,0 +1,68 @@
package cat
import "testing"
// In split, OpsLog stays on the RECEIVE slice.
//
// SmartSDR moves its "active" flag onto the TX slice as soon as the transmit
// frequency is touched. Following it hands the operator the S-meter, audio
// level, filter and DSP of a receiver they are not listening to — while the DX
// they are working is on the other slice.
func TestMainSliceStaysOnRXInSplit(t *testing.T) {
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
}
// Working a DX on 14.100, transmitting up on 14.200. The radio has focused
// the transmitter.
f := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
0: mk(14_100_000, "USB", false, false), // where the DX is heard
1: mk(14_200_000, "USB", true, true), // where we transmit, radio-focused
}}
idx, s := f.mainSliceLocked()
if idx != 0 || s == nil || s.freqHz != 14_100_000 {
t.Errorf("main slice = %d (%v Hz) — want slice 0, the RX side on 14.100", idx, s.freqHz)
}
// Simplex: the radio's focus is authoritative again, nothing to prefer.
f2 := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
0: mk(14_100_000, "USB", false, false),
1: mk(14_200_000, "USB", true, true),
}}
f2.slices[0].inUse = false // only the TX slice is in use → simplex
if idx, _ := f2.mainSliceLocked(); idx != 1 {
t.Errorf("simplex main slice = %d, want 1 — the only slice in use", idx)
}
// Two same-band slices in DIFFERENT classes are not a split (SSB + FT8), so
// there is nothing to prefer and the radio's focus stands.
f3 := &Flex{pinnedSlice: -1, slices: map[int]*flexSlice{
0: mk(14_074_000, "DIGU", false, false),
1: mk(14_200_000, "USB", true, true),
}}
if idx, _ := f3.mainSliceLocked(); idx != 1 {
t.Errorf("SSB+FT8 main slice = %d, want 1 — that is not a split", idx)
}
}
// A slice picked IN OPSLOG wins over everything, including the split rule and
// the radio's own focus. Picking one on the radio does not move OpsLog.
func TestPinnedSliceWins(t *testing.T) {
mk := func(hz int64, mode string, active, tx bool) *flexSlice {
return &flexSlice{freqHz: hz, mode: mode, active: active, tx: tx, inUse: true}
}
f := &Flex{pinnedSlice: 1, slices: map[int]*flexSlice{
0: mk(14_100_000, "USB", true, false), // radio-focused
1: mk(14_200_000, "USB", false, true), // chosen in OpsLog
}}
if idx, _ := f.mainSliceLocked(); idx != 1 {
t.Errorf("main slice = %d — an explicit choice in OpsLog must win", idx)
}
// A pin on a slice that is no longer in use is not a choice any more: it
// must not strand OpsLog on a receiver that has been closed.
f.slices[1].inUse = false
if idx, _ := f.mainSliceLocked(); idx != 0 {
t.Errorf("main slice = %d — a closed slice cannot stay pinned", idx)
}
}