feat(flex): chase a split pile-up on the skimmer's report marker
Working a DXpedition split means guessing where it listens. The useful information is not the callsign the DX answered but the FREQUENCY that station was calling on, and a CW skimmer already marks it: SDC posts each decoded report to the panadapter as a spot. Those spots reach OpsLog through the radio's spot feed. On a marker, the TRANSMIT slice moves there plus a signed offset; the receive slice never moves, because losing the DX is worse than missing a call. The marker text is a setting -- it is chosen in SDC by the operator, so any constant here would be wrong for whoever chose otherwise -- and the switch is a button beside SPLIT, since it is turned on when a DXpedition appears and off when it is worked. Moves are throttled and ignore a marker landing where the slice already is: a busy pile-up produces several reports a second and the slice would otherwise never be anywhere long enough to call. The spot feed is now subscribed to unconditionally. It was tied to OpsLog's own spot overlay, so an operator running SDC with the overlay off received nothing -- the reason no foreign spot was ever logged. The connect-time 'spot clear' stays behind the overlay flag: it wipes every spot on the radio, a skimmer's included.
This commit is contained in:
@@ -487,6 +487,8 @@ type FlexController interface {
|
||||
// keeps freqMHz inside it, re-centring when it must. See Flex.ZoomPan.
|
||||
ZoomPan(bandwidthMHz, freqMHz float64, centre bool) error
|
||||
SetTXSlice(int) error // make slice idx the transmitter (tx=1)
|
||||
// SetTXSliceFrequency moves the TRANSMIT slice only — split pile-up chasing.
|
||||
SetTXSliceFrequency(int64) error
|
||||
SetSplit(bool) error
|
||||
SetNB(bool) error
|
||||
SetNBLevel(int) error
|
||||
|
||||
+87
-4
@@ -95,6 +95,16 @@ type Flex struct {
|
||||
// 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)
|
||||
|
||||
// OnForeignSpot is called for every spot posted to the radio by a program
|
||||
// OTHER than OpsLog, with the spot's callsign field and its frequency.
|
||||
//
|
||||
// A CW skimmer posts what it decodes, so the marker a DX operator's report
|
||||
// leaves on the panadapter arrives here — that is what the split chaser acts
|
||||
// on. Deliberately raw: this package reports what the radio said and does not
|
||||
// decide what a marker looks like, because the marker text is configured in
|
||||
// the skimmer, by the operator, and only they know what they chose.
|
||||
OnForeignSpot func(callsign string, freqHz int64)
|
||||
}
|
||||
|
||||
// panView is one panadapter's visible window, in MHz.
|
||||
@@ -281,11 +291,18 @@ func (f *Flex) Connect() error {
|
||||
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
|
||||
// Always subscribed, even when OpsLog draws no spots of its own: the feed is
|
||||
// read-only and it is how the spots posted by OTHER programs arrive — a CW
|
||||
// skimmer's decoded reports, which the split chaser acts on. Tying the
|
||||
// subscription to our own overlay meant a station using SDC and no OpsLog
|
||||
// spots heard nothing at all.
|
||||
f.send("sub spot all")
|
||||
if f.spotsEnabled {
|
||||
// Subscribe so the radio pushes existing spots (we learn their indices),
|
||||
// then wipe the panadapter so stale spots from a previous session or
|
||||
// another logger are cleared before we start adding our own.
|
||||
f.send("sub spot all")
|
||||
// Wipe the panadapter so stale spots from a previous session or another
|
||||
// logger are cleared before we start adding our own. Only when the
|
||||
// overlay is ours to manage: "spot clear" removes EVERY spot on the
|
||||
// radio, a skimmer's included, and taking those away from an operator
|
||||
// who never asked us to draw anything would be pure vandalism.
|
||||
go f.clearSpotsOnConnect(conn)
|
||||
}
|
||||
return nil
|
||||
@@ -888,6 +905,7 @@ func (f *Flex) handleStatus(payload string) {
|
||||
f.mu.Unlock()
|
||||
if !removed {
|
||||
f.probeForeignSpot(payload)
|
||||
f.reportForeignSpot(payload)
|
||||
}
|
||||
}
|
||||
debugLog.Printf("Flex: status %s", payload)
|
||||
@@ -1417,6 +1435,71 @@ func (f *Flex) probeForeignSpot(payload string) {
|
||||
}
|
||||
}
|
||||
|
||||
// reportForeignSpot hands another program's spot to OnForeignSpot.
|
||||
//
|
||||
// Off the reader goroutine, like OnSpotClick: the handler tunes the radio, and
|
||||
// a command sent from inside the reader would deadlock against the socket it is
|
||||
// reading.
|
||||
func (f *Flex) reportForeignSpot(payload string) {
|
||||
if strings.Contains(payload, "source=OpsLog") {
|
||||
return
|
||||
}
|
||||
handler := f.OnForeignSpot
|
||||
if handler == nil {
|
||||
return
|
||||
}
|
||||
var call string
|
||||
var hz int64
|
||||
for _, kv := range strings.Fields(payload) {
|
||||
eq := strings.IndexByte(kv, '=')
|
||||
if eq <= 0 {
|
||||
continue
|
||||
}
|
||||
switch kv[:eq] {
|
||||
case "callsign":
|
||||
call = kv[eq+1:]
|
||||
case "rx_freq":
|
||||
if mhz, err := strconv.ParseFloat(kv[eq+1:], 64); err == nil {
|
||||
hz = int64(math.Round(mhz * 1e6))
|
||||
}
|
||||
}
|
||||
}
|
||||
if call == "" || hz <= 0 {
|
||||
return
|
||||
}
|
||||
go handler(call, hz)
|
||||
}
|
||||
|
||||
// SetTXSliceFrequency tunes the TRANSMIT slice, leaving the receive slice where
|
||||
// it is. That distinction is the whole point in split: the operator listens to
|
||||
// the DX on one slice and moves the other around the pile-up.
|
||||
func (f *Flex) SetTXSliceFrequency(hz int64) error {
|
||||
if hz <= 0 {
|
||||
return fmt.Errorf("flex: invalid frequency")
|
||||
}
|
||||
f.mu.Lock()
|
||||
idx := -1
|
||||
for i, s := range f.slices {
|
||||
if s != nil && s.inUse && s.tx {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx >= 0 && f.slices[idx] != nil {
|
||||
f.slices[idx].freqHz = hz // optimistic, like SetFrequency
|
||||
}
|
||||
connected := f.conn != nil
|
||||
f.mu.Unlock()
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("flex: no transmit slice")
|
||||
}
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
f.send(fmt.Sprintf("slice t %d %.6f", idx, float64(hz)/1e6))
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user