chore: release v0.26.3
This commit is contained in:
+136
-7
@@ -66,18 +66,36 @@ type Flex struct {
|
||||
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)
|
||||
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||
spotFreq map[int]int64 // spot index → Hz, so a click can report where it was (the trigger message carries only the index)
|
||||
pendingSpotFreq map[int]int64 // seq → Hz, paired with pendingSpot
|
||||
// panWindow is what each panadapter is currently showing, from its own status
|
||||
// (centre and width, both MHz). Tracked because a zoom that only changes the
|
||||
// WIDTH keeps the old centre, and the frequency the operator just clicked can
|
||||
// end up outside the new window — see ZoomPan.
|
||||
panWindow map[string]panView
|
||||
|
||||
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||
|
||||
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||
// clicks one of our spots on the panadapter, with the spot's callsign and
|
||||
// frequency. The host wires this to fill the entry form. Set before Connect.
|
||||
OnSpotClick func(callsign string, freqHz int64)
|
||||
// clicks one of our spots on the panadapter, with the spot's callsign, its
|
||||
// frequency and the mode it was spotted in — everything the host knows about
|
||||
// the spot, since the radio's own notification carries only an index. The host
|
||||
// wires this to fill the entry form and to size the panadapter. Set before Connect.
|
||||
OnSpotClick func(callsign string, freqHz int64, mode string)
|
||||
}
|
||||
|
||||
// panView is one panadapter's visible window, in MHz.
|
||||
type panView struct{ centre, width float64 }
|
||||
|
||||
type flexSlice struct {
|
||||
freqHz int64
|
||||
mode string // raw Flex mode (USB/LSB/CW/DIGU/…)
|
||||
// pan is the panadapter this slice is displayed on ("0x40000000"), taken
|
||||
// from the slice status. Kept PER SLICE rather than as one radio-wide id:
|
||||
// with two slices on two bands there are two panadapters, and zooming the
|
||||
// wrong one moves a display the operator is not looking at.
|
||||
pan string
|
||||
active bool
|
||||
tx bool
|
||||
inUse bool
|
||||
@@ -187,7 +205,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{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotFreq: map[int]int64{}, pendingSpotFreq: map[int]int64{}, panWindow: map[string]panView{}, 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,
|
||||
@@ -248,6 +266,7 @@ func (f *Flex) Connect() error {
|
||||
f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default)
|
||||
f.send("sub profile all") // mic/global/tx profiles (for the mic-profile dropdown)
|
||||
f.send("profile mic info") // request the current mic profile list + selection
|
||||
f.send("sub pan all") // panadapter centre/bandwidth, so a zoom knows where the display already is
|
||||
f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below)
|
||||
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
|
||||
if f.spotsEnabled {
|
||||
@@ -341,11 +360,12 @@ func (f *Flex) reader(conn net.Conn) {
|
||||
f.mu.Lock()
|
||||
call := f.spotCall[idx]
|
||||
mode := f.spotMode[idx]
|
||||
hz := f.spotFreq[idx]
|
||||
handler := f.OnSpotClick
|
||||
f.mu.Unlock()
|
||||
if call != "" && handler != nil {
|
||||
debugLog.Printf("Flex: spot %d triggered → %s (mode %s)", idx, call, mode)
|
||||
go handler(call, 0)
|
||||
go handler(call, hz, mode)
|
||||
// SmartSDR tunes the spot's frequency on click but does NOT apply
|
||||
// its mode=, so set the slice mode ourselves from what we spotted.
|
||||
if mode != "" {
|
||||
@@ -388,14 +408,17 @@ func (f *Flex) reader(conn net.Conn) {
|
||||
f.mu.Lock()
|
||||
call, pending := f.pendingSpot[seq]
|
||||
spotMode := f.pendingSpotMode[seq]
|
||||
spotFreq := f.pendingSpotFreq[seq]
|
||||
if pending {
|
||||
delete(f.pendingSpot, seq)
|
||||
delete(f.pendingSpotMode, seq)
|
||||
delete(f.pendingSpotFreq, seq)
|
||||
}
|
||||
if pending && ok && len(parts) >= 3 {
|
||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||
f.spotCall[idx] = call
|
||||
f.spotMode[idx] = spotMode
|
||||
f.spotFreq[idx] = spotFreq
|
||||
f.spotIdx[idx] = true
|
||||
f.spotByCall[strings.ToUpper(call)] = idx
|
||||
}
|
||||
@@ -692,6 +715,32 @@ func (f *Flex) handleStatus(payload string) {
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
// Panadapter — "display pan 0x40000000 center=14.075 bandwidth=0.2 …".
|
||||
// Only the window matters here; everything else about the display is
|
||||
// SmartSDR's business.
|
||||
if len(fields) >= 3 && fields[0] == "display" && fields[1] == "pan" {
|
||||
id := fields[2]
|
||||
f.mu.Lock()
|
||||
w := f.panWindow[id]
|
||||
for _, kv := range fields[3:] {
|
||||
key, val, ok := splitKV(kv)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "center":
|
||||
if v, err := strconv.ParseFloat(val, 64); err == nil && v > 0 {
|
||||
w.centre = v
|
||||
}
|
||||
case "bandwidth":
|
||||
if v, err := strconv.ParseFloat(val, 64); err == nil && v > 0 {
|
||||
w.width = v
|
||||
}
|
||||
}
|
||||
}
|
||||
f.panWindow[id] = w
|
||||
f.mu.Unlock()
|
||||
}
|
||||
// Meter definitions — "meter <num>.src=… <num>.nam=… <num>.unit=… …".
|
||||
// The unit scales the UDP values, the name labels them; subscribe to each
|
||||
// new id so the radio streams it.
|
||||
@@ -813,6 +862,7 @@ func (f *Flex) handleStatus(payload string) {
|
||||
delete(f.spotIdx, idx)
|
||||
delete(f.spotCall, idx)
|
||||
delete(f.spotMode, idx)
|
||||
delete(f.spotFreq, idx)
|
||||
} else {
|
||||
f.spotIdx[idx] = true
|
||||
}
|
||||
@@ -847,6 +897,8 @@ func (f *Flex) handleStatus(payload string) {
|
||||
}
|
||||
case "mode":
|
||||
s.mode = val
|
||||
case "pan":
|
||||
s.pan = val
|
||||
case "active":
|
||||
s.active = val == "1"
|
||||
case "tx":
|
||||
@@ -1241,6 +1293,74 @@ func (f *Flex) SetMode(mode string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ZoomPan sets the visible width of the ACTIVE slice's panadapter, and puts the
|
||||
// frequency the operator clicked inside it.
|
||||
//
|
||||
// bandwidthMHz is the whole visible span, which is how SmartSDR expresses it —
|
||||
// not a zoom factor.
|
||||
//
|
||||
// Re-centring is the subtle part. SmartSDR keeps the panadapter's CENTRE when
|
||||
// only the width changes, so narrowing from 200 kHz to 25 for a CW spot — or
|
||||
// jumping to a spot clicked in the band map — regularly left the very signal the
|
||||
// operator asked for outside the new window: the radio had tuned to it, but the
|
||||
// display was showing somewhere else. So the centre moves when it must:
|
||||
//
|
||||
// centre == true always re-centre (the operator asked for it)
|
||||
// centre == false re-centre ONLY when freqMHz would fall outside the new
|
||||
// window, leaving a settled display alone the rest of the time
|
||||
//
|
||||
// The test uses 40% of the width either side rather than the full half: a spot
|
||||
// pinned to the last pixel of the panadapter is visible in the arithmetic and
|
||||
// useless in practice.
|
||||
//
|
||||
// A no-op when the slice reports no panadapter, which is the case on a slice
|
||||
// that exists but is not displayed. Silent, because that is not a fault.
|
||||
// needRecentre decides whether the panadapter has to move for freqMHz to be
|
||||
// comfortably visible in a window of bandwidthMHz. forced short-circuits it.
|
||||
//
|
||||
// An unknown current centre (no pan status seen yet) counts as "would fall
|
||||
// outside": the display is more likely wrong than right, and re-centring on the
|
||||
// spot the operator just clicked is never the surprising answer.
|
||||
func needRecentre(cur panView, freqMHz, bandwidthMHz float64, forced bool) bool {
|
||||
if freqMHz <= 0 {
|
||||
return false // nothing to centre on
|
||||
}
|
||||
if forced {
|
||||
return true
|
||||
}
|
||||
return cur.centre <= 0 || math.Abs(freqMHz-cur.centre) > bandwidthMHz*0.4
|
||||
}
|
||||
|
||||
func (f *Flex) ZoomPan(bandwidthMHz, freqMHz float64, centre bool) error {
|
||||
f.mu.Lock()
|
||||
pan := ""
|
||||
// The operator's slice, by the same rule as everything else — see
|
||||
// mainSliceLocked. Zooming any other panadapter would move a display the
|
||||
// operator is not looking at.
|
||||
if _, sl := f.mainSliceLocked(); sl != nil {
|
||||
pan = sl.pan
|
||||
}
|
||||
cur := f.panWindow[pan]
|
||||
connected := f.conn != nil && f.gotHandle
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
if pan == "" || bandwidthMHz <= 0 {
|
||||
return nil
|
||||
}
|
||||
recentre := needRecentre(cur, freqMHz, bandwidthMHz, centre)
|
||||
// Centre first, then width: SmartSDR clamps a window that would run past the
|
||||
// end of the spectrum, and moving to the right place before widening leaves
|
||||
// nothing to clamp.
|
||||
if recentre {
|
||||
f.send(fmt.Sprintf("display pan s %s center=%.6f", pan, freqMHz))
|
||||
}
|
||||
f.send(fmt.Sprintf("display pan s %s bandwidth=%.6f", pan, bandwidthMHz))
|
||||
debugLog.Printf("Flex: zoom pan %s → %.4f MHz (centre=%v on %.6f, was %.6f)", pan, bandwidthMHz, recentre, freqMHz, cur.centre)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendSpot renders a cluster spot on the panadapter via "spot add". Spots carry
|
||||
// a lifetime so the radio expires them on its own (the API has no "spot clear").
|
||||
// Per the SmartSDR API, spaces inside a field value are encoded as 0x7F.
|
||||
@@ -1276,6 +1396,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
||||
delete(f.spotByCall, upperCall)
|
||||
delete(f.spotCall, old)
|
||||
delete(f.spotMode, old)
|
||||
delete(f.spotFreq, old)
|
||||
delete(f.spotIdx, old)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
@@ -1291,6 +1412,12 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
||||
if m := flexEncode(adifModeToFlex(s.Mode, s.FreqHz)); m != "" {
|
||||
cmd += " mode=" + m
|
||||
}
|
||||
// background_color is optional in the API and empty means "the radio's own".
|
||||
// Sent only when set, so a spot with no configured background does not blank
|
||||
// the default to nothing.
|
||||
if bg := flexEncode(s.BackgroundColor); bg != "" {
|
||||
cmd += " background_color=" + bg
|
||||
}
|
||||
if c := flexEncode(s.Comment); c != "" {
|
||||
cmd += " comment=" + c
|
||||
}
|
||||
@@ -1302,6 +1429,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
||||
f.mu.Lock()
|
||||
f.pendingSpot[seq] = s.Callsign
|
||||
f.pendingSpotMode[seq] = s.Mode
|
||||
f.pendingSpotFreq[seq] = s.FreqHz
|
||||
f.mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
@@ -1335,6 +1463,7 @@ func (f *Flex) ClearSpots() error {
|
||||
f.spotIdx = map[int]bool{}
|
||||
f.spotCall = map[int]string{}
|
||||
f.spotMode = map[int]string{}
|
||||
f.spotFreq = map[int]int64{}
|
||||
f.spotByCall = map[string]int{}
|
||||
connected := f.conn != nil
|
||||
f.mu.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user