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)
+}