diff --git a/changelog.json b/changelog.json index 0b30fdf..e844f74 100644 --- a/changelog.json +++ b/changelog.json @@ -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", "date": "", diff --git a/internal/cat/flex.go b/internal/cat/flex.go index 3d87e1a..5c5581d 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -35,7 +35,12 @@ type Flex struct { model string 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) amp flexAmp // external amplifier (PowerGenius XL) state 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{}, meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{}, 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 // flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever. // 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 + chosen := -1 for _, idx := range f.sortedSliceIdxLocked() { s := f.slices[idx] if !s.inUse { @@ -978,11 +994,56 @@ func (f *Flex) mainSliceLocked() (int, *flexSlice) { firstInUse = idx } if s.active { - return idx, s + chosen = idx + break } } - if firstInUse >= 0 { - return firstInUse, f.slices[firstInUse] + if chosen < 0 { + 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 } @@ -1089,6 +1150,11 @@ func (f *Flex) SetActiveSlice(idx int) error { if !exists { 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)) return nil } @@ -1389,6 +1455,7 @@ func (f *Flex) FlexState() FlexTXState { } } sort.Ints(sidx) + mainIdx, _ := f.mainSliceLocked() for _, i := range sidx { s := f.slices[i] st.Slices = append(st.Slices, FlexSliceInfo{ @@ -1397,7 +1464,11 @@ func (f *Flex) FlexState() FlexTXState { FreqHz: s.freqHz, Mode: flexModeToADIF(s.mode), 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, }) } diff --git a/internal/cat/flex_rxslice_test.go b/internal/cat/flex_rxslice_test.go new file mode 100644 index 0000000..9c72984 --- /dev/null +++ b/internal/cat/flex_rxslice_test.go @@ -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) + } +}