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" }