diff --git a/app.go b/app.go index c3bd037..4bb0ce6 100644 --- a/app.go +++ b/app.go @@ -815,6 +815,12 @@ func (a *App) startup(ctx context.Context) { // CAT manager: emit pushes state to the frontend via Wails events, and // forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM). a.cat = cat.NewManager(func(s cat.RigState) { + // DIAGNOSTIC: the manager only fires this on a USER-relevant change, so a + // burst of these lines = the frontend is being re-rendered rapidly (the + // "screen flickers" symptom). Shows WHAT is churning — connection flap, + // or freq/split/mode oscillating between slices during FT8. + applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s", + s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band) if a.ctx != nil { wruntime.EventsEmit(a.ctx, "cat:state", s) } @@ -7870,6 +7876,12 @@ func (a *App) consumeUDPEvents() { if a.udp == nil { return } + // Rate-limit decode spots: an FT8 opening decodes the SAME stations every + // cycle, so spot each call at most once per window (well within its display + // lifetime) instead of blasting the radio with a burst of adds+removes every + // 15 s. Single-goroutine loop → the map needs no lock. + const decodeSpotWindow = 20 * time.Second + lastDecodeSpot := map[string]time.Time{} for ev := range a.udp.Events() { if a.ctx == nil { continue @@ -7880,6 +7892,14 @@ func (a *App) consumeUDPEvents() { // panadapter when the option is on; green + SNR comment, auto-expiring // after the configured duration. De-duped per call in the Flex backend. if a.catFlexDecodeSpots && a.cat != nil { + now := time.Now() + if t, seen := lastDecodeSpot[ev.DecodeCall]; seen && now.Sub(t) < decodeSpotWindow { + continue // spotted this call very recently — don't re-hammer the radio + } + if len(lastDecodeSpot) > 4000 { + lastDecodeSpot = map[string]time.Time{} // bound memory on long sessions + } + lastDecodeSpot[ev.DecodeCall] = now secs := a.catFlexDecodeSecs if secs <= 0 { secs = 120 @@ -8203,6 +8223,7 @@ func (a *App) SetCATFrequency(hz int64) error { if a.cat == nil { return fmt.Errorf("cat not initialized") } + applog.Printf("cat: SetCATFrequency %.3f MHz — deliberate frontend set (spot click / band / memory / entry)", float64(hz)/1e6) err := a.cat.SetFrequency(hz) if err != nil { applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err) @@ -8226,6 +8247,7 @@ func (a *App) ultrabeamFollowNow(freqHz int64) { if err != nil || !s.Enabled || !s.Follow { return } + applog.Printf("ultrabeam: followNow — deliberate CAT set to %.3f MHz", float64(freqHz)/1e6) step := s.StepKHz if step <= 0 { step = 50 @@ -8241,17 +8263,22 @@ func (a *App) ultrabeamFollowNow(freqHz int64) { } } khz := int(freqHz / 1000) - diff := khz - st.Frequency + ref := st.Frequency + if ref <= 0 { + ref = c.LastSetKHz() + } + diff := khz - ref if diff < 0 { diff = -diff } - if st.Frequency > 0 && diff < step { + if ref > 0 && diff < step { + applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step) return // within the deadband — don't chase a tiny QSY } if err := c.SetFrequency(khz, st.Direction); err != nil { applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err) } else { - applog.Printf("ultrabeam: re-tuned on freq set → %d kHz (dir %d)", khz, st.Direction) + applog.Printf("ultrabeam: re-tuned on freq set → %d kHz (dir %d, was ref %d kHz)", khz, st.Direction, ref) } } @@ -9594,6 +9621,7 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan } ticker := time.NewTicker(1500 * time.Millisecond) defer ticker.Stop() + lastRigKHz := 0 // only log when the followed rig frequency actually changes for { select { case <-stop: @@ -9611,6 +9639,15 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan continue } rigKHz := int(rs.FreqHz / 1000) + // Log the moment the poll sees a NEW rig frequency — this is what the + // follow loop chases. If the antenna QSYs unexpectedly, this shows the + // CAT backend started reporting that frequency (e.g. WSJT-X moved the + // dial, or the active slice changed) even though you didn't touch the VFO. + if rigKHz != lastRigKHz { + applog.Printf("ultrabeam: follow loop reads rig freq %.3f MHz (mode %s) — antenna at %d kHz, step %d kHz", + float64(rs.FreqHz)/1e6, rs.Mode, st.Frequency, stepKHz) + lastRigKHz = rigKHz + } // Skip frequencies outside the antenna's tunable range (other band). if st.FreqMin > 0 && st.FreqMax > 0 { rigMHz := rs.FreqHz / 1_000_000 @@ -9618,17 +9655,24 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan continue } } - diff := rigKHz - st.Frequency + // Deadband reference = the antenna's reported freq, or (when it hasn't + // reported one yet) the last freq we commanded — so a 0/blank status + // doesn't bypass the deadband and re-tune on every small QSY. + ref := st.Frequency + if ref <= 0 { + ref = c.LastSetKHz() + } + diff := rigKHz - ref if diff < 0 { diff = -diff } - if st.Frequency > 0 && diff < stepKHz { + if ref > 0 && diff < stepKHz { continue // within the deadband — leave the motors alone } if err := c.SetFrequency(rigKHz, st.Direction); err != nil { applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err) } else { - applog.Printf("ultrabeam: followed rig → %d kHz (dir %d)", rigKHz, st.Direction) + applog.Printf("ultrabeam: followed rig → %d kHz (dir %d, was ref %d kHz, step %d)", rigKHz, st.Direction, ref, stepKHz) } } } diff --git a/frontend/src/components/FlexPanel.tsx b/frontend/src/components/FlexPanel.tsx index 80704aa..8f70131 100644 --- a/frontend/src/components/FlexPanel.tsx +++ b/frontend/src/components/FlexPanel.tsx @@ -37,7 +37,7 @@ type FlexState = { }; 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; slice?: number; value: number; lo: number; hi: number }; const ZERO: FlexState = { available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false, @@ -356,7 +356,15 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number // Radio meters (exclude the amplifier's, which we show separately). const radio = (name: string) => meters.find((m) => (m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP')); - const sig = radio('LEVEL') || radio('SIGNAL'); + // Per-slice (SLC) meters — S-meter — exist once PER SLICE, so pick the + // one for the ACTIVE slice; otherwise we'd always show slice A's level. + const activeSlice = (st.slices || []).find((s) => s.active)?.index ?? -1; + const sliceMeter = (name: string) => { + const m = meters.filter((x) => (x.name || '').toUpperCase().includes(name) && !(x.src || '').toUpperCase().includes('AMP')); + if (m.length === 0) return undefined; + return m.find((x) => (x.src || '').toUpperCase().includes('SLC') && x.slice === activeSlice) || m[0]; + }; + const sig = sliceMeter('LEVEL') || sliceMeter('SIGNAL'); const fwd = radio('FWDPWR'); const swr = radio('SWR'); // Mic input level + speech-compression (voltage & PA temp live in the diff --git a/frontend/src/components/UDPIntegrationsPanel.tsx b/frontend/src/components/UDPIntegrationsPanel.tsx index 502b986..b8a09ef 100644 --- a/frontend/src/components/UDPIntegrationsPanel.tsx +++ b/frontend/src/components/UDPIntegrationsPanel.tsx @@ -321,7 +321,6 @@ function EditDialog({ setDraft((d) => ({ ...d, name: e.target.value }))} /> diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index e53f553..ab214e1 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -504,6 +504,7 @@ export namespace cat { src?: string; name?: string; unit?: string; + slice: number; value: number; lo: number; hi: number; @@ -518,6 +519,7 @@ export namespace cat { this.src = source["src"]; this.name = source["name"]; this.unit = source["unit"]; + this.slice = source["slice"]; this.value = source["value"]; this.lo = source["lo"]; this.hi = source["hi"]; diff --git a/internal/cat/cat.go b/internal/cat/cat.go index 9aa8eaa..5ed747f 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -330,6 +330,7 @@ type FlexMeter struct { Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP… Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP… Unit string `json:"unit,omitempty"` + Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise Value float64 `json:"value"` Lo float64 `json:"lo"` Hi float64 `json:"hi"` diff --git a/internal/cat/flex.go b/internal/cat/flex.go index b4c898a..6af4e4c 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -140,6 +140,7 @@ type meterInfo struct { src string // SLC (slice), TX-, COD, RAD, AMP… name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B… unit string // dbm, dbfs, swr, volts, degc, watts… + slc int // for src=SLC meters, the slice index (the ".num" field); -1 otherwise lo float64 hi float64 } @@ -641,7 +642,7 @@ func (f *Flex) handleStatus(payload string) { // One meter per token; its fields are '#'-separated: // ".src=…#.num=…#.nam=…#.low=…#.hi=…#.unit=…". num := -1 - var mi meterInfo + mi := meterInfo{slc: -1} for _, sub := range strings.Split(tok, "#") { key, val, ok := splitKV(sub) if !ok { @@ -661,6 +662,12 @@ func (f *Flex) handleStatus(payload string) { mi.src = val case "nam": mi.name = val + case "num": + // For a slice (SLC) meter this field is the slice index — + // how we tell slice A's S-meter from slice B's. + if v, e := strconv.Atoi(strings.TrimSpace(val)); e == nil { + mi.slc = v + } case "unit", "units": mi.unit = val case "low", "lo": @@ -675,6 +682,7 @@ func (f *Flex) handleStatus(payload string) { old, seen := f.meterMeta[num] if !seen { newIDs = append(newIDs, num) + old.slc = -1 } if mi.src != "" { old.src = mi.src @@ -685,6 +693,9 @@ func (f *Flex) handleStatus(payload string) { if mi.unit != "" { old.unit = mi.unit } + if mi.slc >= 0 { + old.slc = mi.slc + } if mi.lo != 0 { old.lo = mi.lo } @@ -1079,15 +1090,22 @@ func (f *Flex) SendSpot(s SpotInfo) error { } // De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already // spotted gets its previous spot removed first — one live spot per call, - // refreshed, instead of a pile that all expire independently. + // refreshed, instead of a pile that all expire independently. NB: capture the + // old index under the lock but send OUTSIDE it — f.send() takes f.mu itself, + // and Go mutexes aren't reentrant (calling send while locked deadlocks the + // whole Flex goroutine → the radio drops OFFLINE). + upperCall := strings.ToUpper(s.Callsign) f.mu.Lock() - if old, ok := f.spotByCall[strings.ToUpper(s.Callsign)]; ok { - delete(f.spotByCall, strings.ToUpper(s.Callsign)) + old, hadOld := f.spotByCall[upperCall] + if hadOld { + delete(f.spotByCall, upperCall) delete(f.spotCall, old) delete(f.spotIdx, old) - f.send(fmt.Sprintf("spot remove %d", old)) } f.mu.Unlock() + if hadOld { + f.send(fmt.Sprintf("spot remove %d", old)) + } cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d", float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix()) if m := flexEncode(s.Mode); m != "" { @@ -1321,7 +1339,7 @@ func (f *Flex) FlexState() FlexTXState { sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll for _, id := range ids { mi := f.meterMeta[id] - st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi}) + st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Slice: mi.slc, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi}) } } return st diff --git a/internal/ultrabeam/ultrabeam.go b/internal/ultrabeam/ultrabeam.go index 81e080c..d3cad72 100644 --- a/internal/ultrabeam/ultrabeam.go +++ b/internal/ultrabeam/ultrabeam.go @@ -9,6 +9,7 @@ import ( "fmt" "log" "net" + "runtime" "sync" "time" ) @@ -75,6 +76,20 @@ type Client struct { pendingDir int pendingDirAt time.Time pendingDirSet bool + + // lastSetKHz is the frequency we last COMMANDED. Used as the follow-loop + // deadband reference when the antenna's own status hasn't reported a frequency + // yet (Frequency==0) — otherwise the deadband is bypassed and every small QSY + // re-tunes the motors. + lastSetKHz int +} + +// LastSetKHz returns the frequency (kHz) most recently commanded to the antenna, +// or 0 if none yet. +func (c *Client) LastSetKHz() int { + c.statusMu.RLock() + defer c.statusMu.RUnlock() + return c.lastSetKHz } type Status struct { @@ -457,6 +472,15 @@ func (c *Client) queryProgress() ([]int, error) { // SetFrequency changes frequency and optional direction (command 3) func (c *Client) SetFrequency(freqKhz int, direction int) error { + // Trace WHO asked for the change — the caller's function + line — so an + // unexpected antenna QSY (e.g. jumping to 14.074 while on 40m) can be traced + // to the follow loop, an immediate re-tune, or a direction re-issue. + caller := "?" + if pc, _, line, ok := runtime.Caller(1); ok { + caller = fmt.Sprintf("%s:%d", runtime.FuncForPC(pc).Name(), line) + } + log.Printf("Ultrabeam: SetFrequency(%d kHz, dir %d) ← %s", freqKhz, direction, caller) + data := []byte{ byte(freqKhz & 0xFF), byte((freqKhz >> 8) & 0xFF), @@ -467,6 +491,7 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error { if err == nil { c.statusMu.Lock() c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true + c.lastSetKHz = freqKhz if c.lastStatus != nil { c.lastStatus.Direction = direction // reflect immediately } diff --git a/main.go b/main.go index 5797850..c7ca64f 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,15 @@ func profileArg(args []string) string { } func main() { + // Single-instance guard: if OpsLog is already running, focus that window and + // exit instead of spawning a duplicate. A second process would open its own + // CAT (FlexRadio) connection and Ultrabeam follow loop, and the two would + // fight over the rig/antenna frequency — the cause of "the antenna re-tunes on + // its own" when a windowless zombie instance was left running. + if !acquireSingleInstance() { + return + } + // Create an instance of the app structure app := NewApp() app.startupProfile = profileArg(os.Args[1:]) diff --git a/singleinstance_other.go b/singleinstance_other.go new file mode 100644 index 0000000..b926bfe --- /dev/null +++ b/singleinstance_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +// acquireSingleInstance is a no-op off Windows (the single-instance guard uses a +// Windows named mutex). Always allows the app to start. +func acquireSingleInstance() bool { return true } diff --git a/singleinstance_windows.go b/singleinstance_windows.go new file mode 100644 index 0000000..2df262d --- /dev/null +++ b/singleinstance_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +package main + +import ( + "errors" + "unsafe" + + "golang.org/x/sys/windows" +) + +// singleInstanceName is a per-session named mutex. The Windows kernel releases +// it automatically when the owning process dies (even on a crash), so a +// lingering/zombie OpsLog can't permanently block future launches — killing it +// frees the name at once. Session-local (no "Global\\") = one instance per +// logged-in desktop, which is what we want. +const singleInstanceName = "OpsLog-SingleInstance-Mutex" + +// acquireSingleInstance creates the named mutex. Returns ok=false when another +// OpsLog already holds it (this instance should exit); on the way out it brings +// the existing window to the front so a double-click just refocuses OpsLog +// instead of spawning a duplicate that fights over the CAT / antenna. +// +// The mutex handle is deliberately never closed — it must live for the whole +// process lifetime; the OS reclaims it on exit. +func acquireSingleInstance() (ok bool) { + namePtr, err := windows.UTF16PtrFromString(singleInstanceName) + if err != nil { + return true // never block launch on an unexpected error + } + kernel32 := windows.NewLazySystemDLL("kernel32.dll") + createMutex := kernel32.NewProc("CreateMutexW") + // CreateMutexW(lpSecurityAttributes=NULL, bInitialOwner=FALSE, lpName) + h, _, callErr := createMutex.Call(0, 0, uintptr(unsafe.Pointer(namePtr))) + if h == 0 { + return true // couldn't create the mutex → don't block the app + } + if errors.Is(callErr, windows.ERROR_ALREADY_EXISTS) { + focusExistingWindow() + return false + } + return true +} + +// focusExistingWindow finds the running OpsLog window by its title and restores +// + foregrounds it. Best-effort; failures are silently ignored. +func focusExistingWindow() { + user32 := windows.NewLazySystemDLL("user32.dll") + findWindow := user32.NewProc("FindWindowW") + setForeground := user32.NewProc("SetForegroundWindow") + showWindow := user32.NewProc("ShowWindow") + + title, err := windows.UTF16PtrFromString("OpsLog") + if err != nil { + return + } + hwnd, _, _ := findWindow.Call(0, uintptr(unsafe.Pointer(title))) + if hwnd == 0 { + return + } + const swRestore = 9 // SW_RESTORE — un-minimise if needed + showWindow.Call(hwnd, swRestore) + setForeground.Call(hwnd) +}