From edede0bc1ea80b3fedeec6d8b8f1f3c2c83b25cc Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 30 Jun 2026 09:14:50 +0200 Subject: [PATCH] fix: zoom taking too long after clicking a spot --- app.go | 7 ++ frontend/src/App.tsx | 5 ++ frontend/src/components/FlexPanel.tsx | 20 +++++- frontend/src/components/MainMap.tsx | 5 +- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 ++ frontend/wailsjs/go/models.ts | 6 ++ internal/cat/cat.go | 6 +- internal/cat/flex.go | 94 ++++++++++++++++++++++++--- 9 files changed, 135 insertions(+), 14 deletions(-) diff --git a/app.go b/app.go index e9a887e..10c8f68 100644 --- a/app.go +++ b/app.go @@ -7440,6 +7440,13 @@ func (a *App) FlexSetTXAntenna(ant string) error { return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXAntenna(ant) }) } +func (a *App) FlexSetSplit(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetSplit(on) }) +} + // keyFlexBandAnt stores the per-band RX/TX antenna map (global, machine-local). const keyFlexBandAnt = "flex.band_antennas" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9be7b0d..fdd3a1e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -818,6 +818,7 @@ export default function App() { // worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed), // so it's loaded async on mount and re-read on profile:changed below. type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent'; + const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now const [mainPaneLeft, setMainPaneLeft] = useState('map1'); const [mainPaneRight, setMainPaneRight] = useState('map2'); const loadMainPanes = useCallback(async () => { @@ -2028,6 +2029,9 @@ export default function App() { qsl_via: d.qsl_via || (r.qsl_via ?? ''), })); if (r.dxcc && r.dxcc > 0) runWorkedBefore(call, r.dxcc); + // The DX location is now known (grid set above) — force the world map to + // auto-zoom right away, so it doesn't lag behind the resolved QSO. + setMapZoomSignal((n) => n + 1); // Recording: tie it to the resolved callsign. Start once a real (≥3-char) // call resolves — covers the fast CW workflow (type → Enter, no blur). If // we're already recording a DIFFERENT call (the operator edited the @@ -2823,6 +2827,7 @@ export default function App() { toLabel={callsign} beamAzimuths={showBeamOnMap ? beamHeadings : []} boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null} + zoomSignal={mapZoomSignal} /> ); case 'map2': diff --git a/frontend/src/components/FlexPanel.tsx b/frontend/src/components/FlexPanel.tsx index 9b31e89..dbe4950 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, + FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel, FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay, FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter, @@ -21,6 +21,7 @@ type FlexState = { atu_status?: string; atu_memories: boolean; rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number; mute: boolean; rx_ant?: string; tx_ant?: string; ant_list?: string[]; tx_ant_list?: string[]; + split: boolean; rx_freq_hz?: number; tx_freq_hz?: number; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number; mode?: string; cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number; @@ -35,7 +36,7 @@ const ZERO: FlexState = { available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false, vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0, mon: false, mon_level: 0, mic_level: 0, atu_memories: false, - rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, + rx_avail: false, agc_threshold: 0, audio_level: 0, mute: false, split: false, nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0, cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0, apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0, @@ -306,6 +307,21 @@ export function FlexPanel() { MOX +
+ + {st.split && !!st.tx_freq_hz && ( + + TX {(st.tx_freq_hz / 1e6).toFixed(3)} + {!!st.rx_freq_hz && ` (${(st.tx_freq_hz - st.rx_freq_hz) >= 0 ? '+' : ''}${((st.tx_freq_hz - st.rx_freq_hz) / 1000).toFixed(1)} kHz)`} + + )} +
{!isCW ? (
diff --git a/frontend/src/components/MainMap.tsx b/frontend/src/components/MainMap.tsx index b2d5ea8..75599b2 100644 --- a/frontend/src/components/MainMap.tsx +++ b/frontend/src/components/MainMap.tsx @@ -81,10 +81,11 @@ interface WorldProps { beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each beamWidth?: number; // beamwidth (deg), default 30 boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line + zoomSignal?: number; // bump to force an auto-zoom now (e.g. QRZ lookup finished) } // WorldMap — great-circle path + beam lobe(s), the "map1" pane. -export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) { +export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth, zoomSignal }: WorldProps) { const worldRef = useRef(null); const worldMap = useRef(null); const worldOverlay = useRef(null); @@ -225,7 +226,7 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b } setTimeout(() => { wm.invalidateSize(); }, 0); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]); + }, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom, zoomSignal]); const path = pathBetween(fromGrid, toGrid); diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index d61892f..e527aaf 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -196,6 +196,8 @@ export function FlexSetRXAntenna(arg1:string):Promise; export function FlexSetSidetoneLevel(arg1:number):Promise; +export function FlexSetSplit(arg1:boolean):Promise; + export function FlexSetTXAntenna(arg1:string):Promise; export function FlexSetTunePower(arg1:number):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index d3f474b..bea40b2 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -358,6 +358,10 @@ export function FlexSetSidetoneLevel(arg1) { return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1); } +export function FlexSetSplit(arg1) { + return window['go']['main']['App']['FlexSetSplit'](arg1); +} + export function FlexSetTXAntenna(arg1) { return window['go']['main']['App']['FlexSetTXAntenna'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 2153921..6066881 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -512,6 +512,9 @@ export namespace cat { atu_status?: string; atu_memories: boolean; rx_avail: boolean; + split: boolean; + rx_freq_hz?: number; + tx_freq_hz?: number; agc_mode?: string; agc_threshold: number; audio_level: number; @@ -565,6 +568,9 @@ export namespace cat { this.atu_status = source["atu_status"]; this.atu_memories = source["atu_memories"]; this.rx_avail = source["rx_avail"]; + this.split = source["split"]; + this.rx_freq_hz = source["rx_freq_hz"]; + this.tx_freq_hz = source["tx_freq_hz"]; this.agc_mode = source["agc_mode"]; this.agc_threshold = source["agc_threshold"]; this.audio_level = source["audio_level"]; diff --git a/internal/cat/cat.go b/internal/cat/cat.go index 3d1de0f..01acba6 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -247,7 +247,10 @@ type FlexTXState struct { ATUStatus string `json:"atu_status,omitempty"` ATUMemories bool `json:"atu_memories"` // Active RX slice DSP controls. - RXAvail bool `json:"rx_avail"` // an RX slice exists + RXAvail bool `json:"rx_avail"` // an RX slice exists + Split bool `json:"split"` // RX/TX on separate slices + RXFreqHz int64 `json:"rx_freq_hz,omitempty"` // RX slice freq when split + TXFreqHz int64 `json:"tx_freq_hz,omitempty"` // TX slice freq when split AGCMode string `json:"agc_mode,omitempty"` AGCThreshold int `json:"agc_threshold"` AudioLevel int `json:"audio_level"` @@ -320,6 +323,7 @@ type FlexController interface { SetMute(bool) error SetRXAntenna(string) error SetTXAntenna(string) error + SetSplit(bool) error SetNB(bool) error SetNBLevel(int) error SetNR(bool) error diff --git a/internal/cat/flex.go b/internal/cat/flex.go index 18cac71..7021f02 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -54,6 +54,7 @@ type Flex struct { spotsEnabled bool // push cluster spots + manage the panadapter overlay spotIdx map[int]bool // panadapter spot indices currently known to the radio pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response + pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create) spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click) sentCmds map[int]string // seq → command text, so an R error names the command @@ -148,7 +149,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex { return &Flex{ host: strings.TrimSpace(host), port: port, slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled, - spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, + spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, 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{}, } @@ -314,7 +315,18 @@ func (f *Flex) reader(conn net.Conn) { f.spotIdx[idx] = true } } + // A successful "slice create" for split returns the new slice's index; + // make that slice the transmitter so RX/TX are on separate slices. + splitSeq := f.pendingSplit[seq] + if splitSeq { + delete(f.pendingSplit, seq) + } f.mu.Unlock() + if splitSeq && ok && len(parts) >= 3 { + if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil { + f.send(fmt.Sprintf("slice s %d tx=1", idx)) + } + } } } // Connection ended. @@ -1050,6 +1062,11 @@ 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 { + st.Split = true + st.RXFreqHz = rx.freqHz + st.TXFreqHz = tx.freqHz + } if _, rx := f.rxSliceLocked(); rx != nil { st.RXAvail = true st.Mode = strings.ToUpper(rx.mode) @@ -1170,14 +1187,73 @@ func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", func (f *Flex) SetMute(on bool) error { return f.sendSlice("audio_mute", boolFlex(on)) } func (f *Flex) SetRXAntenna(a string) error { return f.sendSlice("rxant", a) } func (f *Flex) SetTXAntenna(a string) error { return f.sendSlice("txant", a) } -func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) } -func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) } -func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) } -func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) } -func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) } -func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) } -func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) } -func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) } + +// SetSplit toggles 2-slice split like SmartSDR's SPLIT button. ON creates a TX +// slice at RX freq + offset (+1 kHz on CW, +5 kHz otherwise) and makes it the +// transmitter; OFF removes the extra TX slice and returns to simplex (RX slice +// transmits again). +func (f *Flex) SetSplit(on bool) error { + f.mu.Lock() + if f.conn == nil { + f.mu.Unlock() + return fmt.Errorf("flex: not connected") + } + rx, tx := f.pickSlicesLocked() + rxIdx, txIdx := -1, -1 + for i, s := range f.slices { + if s == rx { + rxIdx = i + } + if s == tx { + 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 + } + f.mu.Unlock() + + if on { + if alreadySplit || rx == nil { + return nil // already split (or no slice) + } + offset := int64(5000) + if strings.HasPrefix(strings.ToUpper(rxMode), "CW") { + offset = 1000 + } + cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(rxFreq+offset)/1e6, rxMode) + if rxAnt != "" { + cmd += " ant=" + rxAnt + } + if seq := f.send(cmd); seq > 0 { + f.mu.Lock() + f.pendingSplit[seq] = true // mark the new slice TX once its index returns + f.mu.Unlock() + } + return nil + } + if !alreadySplit { + return nil + } + if txIdx >= 0 { + f.send(fmt.Sprintf("slice remove %d", txIdx)) + } + if rxIdx >= 0 { + f.send(fmt.Sprintf("slice s %d tx=1", rxIdx)) + } + return nil +} +func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) } +func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) } +func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) } +func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) } +func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) } +func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) } +func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) } +func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) } // ── CW keyer controls (top-level "cw" commands) ──