GetFlexRSTChase read settings.Get with profileScope() prepended, but the store already prefixes the active profile: it wrote p3.flex.rst_chase and read p3.p3.flex.rst_chase. The switch went on in the UI, was saved, and came back off on the next spot -- so the feature did nothing, silently. Every refusal after a marker is recognised is now logged with its reason. The operator has just seen a report marked on the panadapter; if the slice does not move, the log has to say what stopped it.
188 lines
6.7 KiB
Go
188 lines
6.7 KiB
Go
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
|
|
}
|
|
// settingOr, NOT settings.Get with profileScope(): the store already applies
|
|
// the active profile's prefix, so scoping the key here wrote p3.flex.rst_chase
|
|
// and read p3.p3.flex.rst_chase — the switch could never be read back on, and
|
|
// the feature did nothing at all with no sign of why.
|
|
if v := a.settingOr(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
|
|
}
|
|
// From here on every refusal is logged. A marker WAS recognised, so the
|
|
// operator is entitled to know why the slice stayed where it was — silence
|
|
// at this point is indistinguishable from a feature that does not work.
|
|
if cfg.SplitOnly && !a.cat.State().Split {
|
|
applog.Printf("rst chase: %s marked at %s, but the radio is not in split — ignored",
|
|
strings.ToUpper(callsign), hzText(freqHz))
|
|
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"
|
|
}
|