Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7398261c50 | ||
|
|
73f3ec51f7 |
@@ -815,6 +815,12 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// CAT manager: emit pushes state to the frontend via Wails events, and
|
// CAT manager: emit pushes state to the frontend via Wails events, and
|
||||||
// forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM).
|
// forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM).
|
||||||
a.cat = cat.NewManager(func(s cat.RigState) {
|
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 {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||||
}
|
}
|
||||||
@@ -7870,6 +7876,12 @@ func (a *App) consumeUDPEvents() {
|
|||||||
if a.udp == nil {
|
if a.udp == nil {
|
||||||
return
|
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() {
|
for ev := range a.udp.Events() {
|
||||||
if a.ctx == nil {
|
if a.ctx == nil {
|
||||||
continue
|
continue
|
||||||
@@ -7880,6 +7892,14 @@ func (a *App) consumeUDPEvents() {
|
|||||||
// panadapter when the option is on; green + SNR comment, auto-expiring
|
// panadapter when the option is on; green + SNR comment, auto-expiring
|
||||||
// after the configured duration. De-duped per call in the Flex backend.
|
// after the configured duration. De-duped per call in the Flex backend.
|
||||||
if a.catFlexDecodeSpots && a.cat != nil {
|
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
|
secs := a.catFlexDecodeSecs
|
||||||
if secs <= 0 {
|
if secs <= 0 {
|
||||||
secs = 120
|
secs = 120
|
||||||
@@ -8203,6 +8223,7 @@ func (a *App) SetCATFrequency(hz int64) error {
|
|||||||
if a.cat == nil {
|
if a.cat == nil {
|
||||||
return fmt.Errorf("cat not initialized")
|
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)
|
err := a.cat.SetFrequency(hz)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("cat: SetFrequency(%d Hz) dispatch error: %v", hz, err)
|
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 {
|
if err != nil || !s.Enabled || !s.Follow {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
applog.Printf("ultrabeam: followNow — deliberate CAT set to %.3f MHz", float64(freqHz)/1e6)
|
||||||
step := s.StepKHz
|
step := s.StepKHz
|
||||||
if step <= 0 {
|
if step <= 0 {
|
||||||
step = 50
|
step = 50
|
||||||
@@ -8241,17 +8263,22 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
khz := int(freqHz / 1000)
|
khz := int(freqHz / 1000)
|
||||||
diff := khz - st.Frequency
|
ref := st.Frequency
|
||||||
|
if ref <= 0 {
|
||||||
|
ref = c.LastSetKHz()
|
||||||
|
}
|
||||||
|
diff := khz - ref
|
||||||
if diff < 0 {
|
if diff < 0 {
|
||||||
diff = -diff
|
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
|
return // within the deadband — don't chase a tiny QSY
|
||||||
}
|
}
|
||||||
if err := c.SetFrequency(khz, st.Direction); err != nil {
|
if err := c.SetFrequency(khz, st.Direction); err != nil {
|
||||||
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
|
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
|
||||||
} else {
|
} 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)
|
ticker := time.NewTicker(1500 * time.Millisecond)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
lastRigKHz := 0 // only log when the followed rig frequency actually changes
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-stop:
|
case <-stop:
|
||||||
@@ -9611,6 +9639,15 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
rigKHz := int(rs.FreqHz / 1000)
|
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).
|
// Skip frequencies outside the antenna's tunable range (other band).
|
||||||
if st.FreqMin > 0 && st.FreqMax > 0 {
|
if st.FreqMin > 0 && st.FreqMax > 0 {
|
||||||
rigMHz := rs.FreqHz / 1_000_000
|
rigMHz := rs.FreqHz / 1_000_000
|
||||||
@@ -9618,17 +9655,24 @@ func (a *App) ultrabeamFollowLoop(c *ultrabeam.Client, stepKHz int, stop <-chan
|
|||||||
continue
|
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 {
|
if diff < 0 {
|
||||||
diff = -diff
|
diff = -diff
|
||||||
}
|
}
|
||||||
if st.Frequency > 0 && diff < stepKHz {
|
if ref > 0 && diff < stepKHz {
|
||||||
continue // within the deadband — leave the motors alone
|
continue // within the deadband — leave the motors alone
|
||||||
}
|
}
|
||||||
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
|
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
|
||||||
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
|
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
|
||||||
} else {
|
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ type FlexState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type FlexSlice = { index: number; letter: string; freq_hz: number; mode?: string; band?: string; active: boolean; tx: boolean };
|
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 = {
|
const ZERO: FlexState = {
|
||||||
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
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).
|
// Radio meters (exclude the amplifier's, which we show separately).
|
||||||
const radio = (name: string) => meters.find((m) =>
|
const radio = (name: string) => meters.find((m) =>
|
||||||
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
(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 fwd = radio('FWDPWR');
|
||||||
const swr = radio('SWR');
|
const swr = radio('SWR');
|
||||||
// Mic input level + speech-compression (voltage & PA temp live in the
|
// Mic input level + speech-compression (voltage & PA temp live in the
|
||||||
|
|||||||
@@ -321,7 +321,6 @@ function EditDialog({
|
|||||||
<Label>{t('udpp.name')}</Label>
|
<Label>{t('udpp.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder={draft.direction === 'inbound' ? t('udpp.namePhInbound') : t('udpp.namePhOutbound')}
|
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.19.4';
|
export const APP_VERSION = '0.19.5';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
@@ -504,6 +504,7 @@ export namespace cat {
|
|||||||
src?: string;
|
src?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
|
slice: number;
|
||||||
value: number;
|
value: number;
|
||||||
lo: number;
|
lo: number;
|
||||||
hi: number;
|
hi: number;
|
||||||
@@ -518,6 +519,7 @@ export namespace cat {
|
|||||||
this.src = source["src"];
|
this.src = source["src"];
|
||||||
this.name = source["name"];
|
this.name = source["name"];
|
||||||
this.unit = source["unit"];
|
this.unit = source["unit"];
|
||||||
|
this.slice = source["slice"];
|
||||||
this.value = source["value"];
|
this.value = source["value"];
|
||||||
this.lo = source["lo"];
|
this.lo = source["lo"];
|
||||||
this.hi = source["hi"];
|
this.hi = source["hi"];
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ type FlexMeter struct {
|
|||||||
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
||||||
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
||||||
Unit string `json:"unit,omitempty"`
|
Unit string `json:"unit,omitempty"`
|
||||||
|
Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise
|
||||||
Value float64 `json:"value"`
|
Value float64 `json:"value"`
|
||||||
Lo float64 `json:"lo"`
|
Lo float64 `json:"lo"`
|
||||||
Hi float64 `json:"hi"`
|
Hi float64 `json:"hi"`
|
||||||
|
|||||||
+24
-6
@@ -140,6 +140,7 @@ type meterInfo struct {
|
|||||||
src string // SLC (slice), TX-, COD, RAD, AMP…
|
src string // SLC (slice), TX-, COD, RAD, AMP…
|
||||||
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
|
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
|
||||||
unit string // dbm, dbfs, swr, volts, degc, watts…
|
unit string // dbm, dbfs, swr, volts, degc, watts…
|
||||||
|
slc int // for src=SLC meters, the slice index (the ".num" field); -1 otherwise
|
||||||
lo float64
|
lo float64
|
||||||
hi float64
|
hi float64
|
||||||
}
|
}
|
||||||
@@ -641,7 +642,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
// One meter per token; its fields are '#'-separated:
|
// One meter per token; its fields are '#'-separated:
|
||||||
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
|
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
|
||||||
num := -1
|
num := -1
|
||||||
var mi meterInfo
|
mi := meterInfo{slc: -1}
|
||||||
for _, sub := range strings.Split(tok, "#") {
|
for _, sub := range strings.Split(tok, "#") {
|
||||||
key, val, ok := splitKV(sub)
|
key, val, ok := splitKV(sub)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -661,6 +662,12 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
mi.src = val
|
mi.src = val
|
||||||
case "nam":
|
case "nam":
|
||||||
mi.name = val
|
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":
|
case "unit", "units":
|
||||||
mi.unit = val
|
mi.unit = val
|
||||||
case "low", "lo":
|
case "low", "lo":
|
||||||
@@ -675,6 +682,7 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
old, seen := f.meterMeta[num]
|
old, seen := f.meterMeta[num]
|
||||||
if !seen {
|
if !seen {
|
||||||
newIDs = append(newIDs, num)
|
newIDs = append(newIDs, num)
|
||||||
|
old.slc = -1
|
||||||
}
|
}
|
||||||
if mi.src != "" {
|
if mi.src != "" {
|
||||||
old.src = mi.src
|
old.src = mi.src
|
||||||
@@ -685,6 +693,9 @@ func (f *Flex) handleStatus(payload string) {
|
|||||||
if mi.unit != "" {
|
if mi.unit != "" {
|
||||||
old.unit = mi.unit
|
old.unit = mi.unit
|
||||||
}
|
}
|
||||||
|
if mi.slc >= 0 {
|
||||||
|
old.slc = mi.slc
|
||||||
|
}
|
||||||
if mi.lo != 0 {
|
if mi.lo != 0 {
|
||||||
old.lo = mi.lo
|
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
|
// 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,
|
// 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()
|
f.mu.Lock()
|
||||||
if old, ok := f.spotByCall[strings.ToUpper(s.Callsign)]; ok {
|
old, hadOld := f.spotByCall[upperCall]
|
||||||
delete(f.spotByCall, strings.ToUpper(s.Callsign))
|
if hadOld {
|
||||||
|
delete(f.spotByCall, upperCall)
|
||||||
delete(f.spotCall, old)
|
delete(f.spotCall, old)
|
||||||
delete(f.spotIdx, old)
|
delete(f.spotIdx, old)
|
||||||
f.send(fmt.Sprintf("spot remove %d", old))
|
|
||||||
}
|
}
|
||||||
f.mu.Unlock()
|
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",
|
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())
|
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
|
||||||
if m := flexEncode(s.Mode); m != "" {
|
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
|
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
mi := f.meterMeta[id]
|
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
|
return st
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -75,6 +76,20 @@ type Client struct {
|
|||||||
pendingDir int
|
pendingDir int
|
||||||
pendingDirAt time.Time
|
pendingDirAt time.Time
|
||||||
pendingDirSet bool
|
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 {
|
type Status struct {
|
||||||
@@ -457,6 +472,15 @@ func (c *Client) queryProgress() ([]int, error) {
|
|||||||
|
|
||||||
// SetFrequency changes frequency and optional direction (command 3)
|
// SetFrequency changes frequency and optional direction (command 3)
|
||||||
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
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{
|
data := []byte{
|
||||||
byte(freqKhz & 0xFF),
|
byte(freqKhz & 0xFF),
|
||||||
byte((freqKhz >> 8) & 0xFF),
|
byte((freqKhz >> 8) & 0xFF),
|
||||||
@@ -467,6 +491,7 @@ func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
||||||
|
c.lastSetKHz = freqKhz
|
||||||
if c.lastStatus != nil {
|
if c.lastStatus != nil {
|
||||||
c.lastStatus.Direction = direction // reflect immediately
|
c.lastStatus.Direction = direction // reflect immediately
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ func profileArg(args []string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
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
|
// Create an instance of the app structure
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
app.startupProfile = profileArg(os.Args[1:])
|
app.startupProfile = profileArg(os.Args[1:])
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.19.4"
|
appVersion = "0.19.5"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user