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:
+178
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
// Chasing a split pile-up by the report the DX just sent.
|
||||
//
|
||||
// Working a DXpedition in split means guessing where it is listening. The DX
|
||||
// answers one station, sends "5NN", and moves on; the useful information is
|
||||
// therefore not the callsign it answered but the FREQUENCY that callsign was
|
||||
// transmitting on, because the DX's receiver was there a second ago.
|
||||
//
|
||||
// A CW skimmer already knows this. SDC (Software Defined Connector) decodes the
|
||||
// whole pile-up and marks each report on the panadapter as a spot — the marker
|
||||
// TEXT is configured in SDC by the operator ("599" for a fresh report, "X" for
|
||||
// an older one, by default). Those spots reach OpsLog already: it subscribes to
|
||||
// the radio's spot feed, and every spot another program posts arrives on
|
||||
// Flex.OnForeignSpot.
|
||||
//
|
||||
// So the whole feature is: recognise the marker, move the TRANSMIT slice there,
|
||||
// leave the receive slice on the DX. Nothing here decodes anything.
|
||||
//
|
||||
// Three deliberate choices:
|
||||
//
|
||||
// - the marker text is a SETTING, not a constant. It is chosen in SDC, and
|
||||
// any guess made here would be wrong for the operator who chose otherwise.
|
||||
// - only the transmit slice moves, never the receive slice. Losing the DX is
|
||||
// a worse outcome than a missed call.
|
||||
// - the offset is signed and in Hz, because working "up a bit" from where the
|
||||
// last station was answered is exactly how a pile-up is chased.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/cat"
|
||||
)
|
||||
|
||||
const keyFlexRSTChase = "flex.rst_chase"
|
||||
|
||||
// FlexRSTChase is the whole configuration.
|
||||
type FlexRSTChase struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// Markers are the skimmer's marker texts, comma-separated ("599,5NN").
|
||||
// Matched against the spot's callsign field, case-insensitively and whole:
|
||||
// a spot IS the marker or it is an ordinary callsign, and a substring rule
|
||||
// would drag in any station whose call happens to contain the digits.
|
||||
Markers string `json:"markers"`
|
||||
// OffsetHz is added to the marker's frequency. Signed: chasing upward from
|
||||
// the last station worked is the usual tactic, downward happens too.
|
||||
OffsetHz int `json:"offset_hz"`
|
||||
// SplitOnly refuses to act when the radio is not in split. On by default:
|
||||
// out of split the transmit slice IS the receive slice, so "move the TX
|
||||
// slice" would take the operator off the DX they are listening to.
|
||||
SplitOnly bool `json:"split_only"`
|
||||
}
|
||||
|
||||
var defaultFlexRSTChase = FlexRSTChase{Enabled: false, Markers: "599", OffsetHz: 0, SplitOnly: true}
|
||||
|
||||
// rstChaseMinGap throttles the moves. A skimmer marks every report it decodes,
|
||||
// and a busy pile-up produces several a second; without a floor the transmit
|
||||
// slice would twitch continuously and never be anywhere long enough to call.
|
||||
const rstChaseMinGap = 700 * time.Millisecond
|
||||
|
||||
// rstChaseMinStep ignores a marker that lands where the slice already is.
|
||||
// Re-sending the same frequency is not free: it is a command to the radio and a
|
||||
// slice status back, several times a second, for no change at all.
|
||||
const rstChaseMinStep = 20 // Hz
|
||||
|
||||
var (
|
||||
rstChaseMu sync.Mutex
|
||||
rstChaseLast time.Time
|
||||
rstChaseFreq int64
|
||||
)
|
||||
|
||||
// GetFlexRSTChase returns the stored configuration (defaults when unset).
|
||||
func (a *App) GetFlexRSTChase() FlexRSTChase {
|
||||
s := defaultFlexRSTChase
|
||||
if a.settings == nil {
|
||||
return s
|
||||
}
|
||||
if v, _ := a.settings.Get(a.ctx, a.profileScope()+keyFlexRSTChase); strings.TrimSpace(v) != "" {
|
||||
_ = json.Unmarshal([]byte(v), &s)
|
||||
}
|
||||
return normRSTChase(s)
|
||||
}
|
||||
|
||||
// SaveFlexRSTChase stores the configuration.
|
||||
func (a *App) SaveFlexRSTChase(s FlexRSTChase) error {
|
||||
b, err := json.Marshal(normRSTChase(s))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.setSetting(keyFlexRSTChase, string(b))
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetFlexRSTChaseEnabled flips the switch alone.
|
||||
//
|
||||
// Its own binding because this belongs on a button in the panel, not in a
|
||||
// settings page: it is turned on when a DXpedition appears and off when it is
|
||||
// worked, which is a thing done mid-QSO with one hand.
|
||||
func (a *App) SetFlexRSTChaseEnabled(on bool) error {
|
||||
s := a.GetFlexRSTChase()
|
||||
s.Enabled = on
|
||||
return a.SaveFlexRSTChase(s)
|
||||
}
|
||||
|
||||
func normRSTChase(s FlexRSTChase) FlexRSTChase {
|
||||
if strings.TrimSpace(s.Markers) == "" {
|
||||
s.Markers = defaultFlexRSTChase.Markers
|
||||
}
|
||||
// A pile-up is a few kHz wide. Anything past that is a typo (Hz entered as
|
||||
// if it were kHz), and honouring it would transmit far outside the segment.
|
||||
if s.OffsetHz > 10000 {
|
||||
s.OffsetHz = 10000
|
||||
}
|
||||
if s.OffsetHz < -10000 {
|
||||
s.OffsetHz = -10000
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// rstChaseMarkerSet splits the configured markers into a comparison set.
|
||||
func rstChaseMarkerSet(markers string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range strings.FieldsFunc(markers, func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
|
||||
if m = strings.ToUpper(strings.TrimSpace(m)); m != "" {
|
||||
out[m] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleForeignSpot is what a skimmer's spot arrives at.
|
||||
func (a *App) handleForeignSpot(callsign string, freqHz int64) {
|
||||
cfg := a.GetFlexRSTChase()
|
||||
if !cfg.Enabled || a.cat == nil {
|
||||
return
|
||||
}
|
||||
if !rstChaseMarkerSet(cfg.Markers)[strings.ToUpper(strings.TrimSpace(callsign))] {
|
||||
return // an ordinary spot: another station's callsign, not a report
|
||||
}
|
||||
if cfg.SplitOnly && !a.cat.State().Split {
|
||||
return
|
||||
}
|
||||
target := freqHz + int64(cfg.OffsetHz)
|
||||
if target <= 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
rstChaseMu.Lock()
|
||||
if now.Sub(rstChaseLast) < rstChaseMinGap {
|
||||
rstChaseMu.Unlock()
|
||||
return
|
||||
}
|
||||
if rstChaseFreq != 0 && absInt64(target-rstChaseFreq) < rstChaseMinStep {
|
||||
rstChaseMu.Unlock()
|
||||
return
|
||||
}
|
||||
rstChaseLast, rstChaseFreq = now, target
|
||||
rstChaseMu.Unlock()
|
||||
|
||||
if err := a.cat.FlexDo(func(fc cat.FlexController) error {
|
||||
return fc.SetTXSliceFrequency(target)
|
||||
}); err != nil {
|
||||
applog.Printf("rst chase: moving the TX slice to %s failed: %v", hzText(target), err)
|
||||
return
|
||||
}
|
||||
applog.Printf("rst chase: %s marked at %s → TX slice to %s (offset %+d Hz)",
|
||||
strings.ToUpper(callsign), hzText(freqHz), hzText(target), cfg.OffsetHz)
|
||||
}
|
||||
|
||||
// hzText renders a frequency the way an operator reads one on a dial.
|
||||
func hzText(hz int64) string {
|
||||
return strconv.FormatFloat(float64(hz)/1000, 'f', 3, 64) + " kHz"
|
||||
}
|
||||
Reference in New Issue
Block a user