feat(cluster): self-spot on the master node while logging
Settings -> DX Cluster: a toggle and an interval. When it is on, logging a QSO announces the station on the master cluster — the QSO's own station callsign as the DX, on the frequency the contact was made on — so callers find the run without waiting for someone else to spot it. The node fills the DE field from the login, so the spotter is us too: a self-spot. It fires on the FIRST QSO of a frequency and then at most once per interval. Both halves matter: announcing every QSO would flood the node and get the station filtered out, while a pure timer would stay silent for minutes after a band change. A drift of up to 500 Hz still counts as the same run, so nudging the VFO mid-pileup does not re-announce. Five minutes is the floor, clamped in SaveSelfSpotSettings as well as in the input: the limit protects the node from us, so it must not depend on the frontend. The interval input keeps raw text and clamps on blur — clamping per keystroke rewrote "10" to "5" as soon as the "1" landed. Wired into both log paths (manual entry and UDP auto-log) on the async side, so a cluster that is slow or down never holds up logging. A send failure restores the previous throttle state, so the next QSO retries instead of sitting out an interval that produced no spot.
This commit is contained in:
@@ -287,7 +287,9 @@ const (
|
||||
keyWKCWLine = "winkeyer.cw_key_line" // serial engine: "dtr" (CW) / "rts" (PTT) or swapped
|
||||
keyWKCWInvert = "winkeyer.cw_invert" // serial engine: invert line polarity (active-LOW)
|
||||
|
||||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||||
keyClusterAutoConnect = "cluster.auto_connect" // open every enabled server at app start
|
||||
keyClusterSelfSpot = "cluster.self_spot" // "1" → announce ourselves on the cluster as we log
|
||||
keyClusterSelfSpotMin = "cluster.self_spot_minutes" // shortest gap between two self-spots
|
||||
|
||||
keyScpEnabled = "scp.enabled" // Super Check Partial / N+1 suggestions on
|
||||
|
||||
@@ -581,31 +583,36 @@ type App struct {
|
||||
// or when a setting that shapes the maps flips.
|
||||
clusterStatusIdx *clusterStatusCache
|
||||
clusterStatusMu sync.Mutex
|
||||
pota *pota.Cache
|
||||
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
|
||||
awardRefs *awardref.Repo
|
||||
qslTemplates *qslcard.Repo
|
||||
operating *operating.Repo
|
||||
udp *udp.Manager
|
||||
udpRepo *udp.Repo
|
||||
extsvc *extsvc.Manager
|
||||
winkeyer *winkeyer.Manager
|
||||
clublog *clublog.Manager
|
||||
clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in)
|
||||
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
|
||||
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
||||
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
|
||||
motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window)
|
||||
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
|
||||
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
||||
tunergenius *tunergenius.Client // Tuner Genius XL (4O3A) ATU (TCP); nil when disabled
|
||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||
spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
|
||||
acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
|
||||
ampsMu sync.Mutex // guards ampInsts
|
||||
ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID
|
||||
audioMgr *audio.Manager
|
||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||
// Self-spot throttle: when and on what frequency we last announced ourselves.
|
||||
// Held in memory only — a restart legitimately re-announces the station.
|
||||
selfSpotMu sync.Mutex
|
||||
selfSpotAt time.Time
|
||||
selfSpotHz int64
|
||||
pota *pota.Cache
|
||||
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
|
||||
awardRefs *awardref.Repo
|
||||
qslTemplates *qslcard.Repo
|
||||
operating *operating.Repo
|
||||
udp *udp.Manager
|
||||
udpRepo *udp.Repo
|
||||
extsvc *extsvc.Manager
|
||||
winkeyer *winkeyer.Manager
|
||||
clublog *clublog.Manager
|
||||
clublogMW *clublog.MostWanted // ClubLog "Most Wanted" DXCC ranking (opt-in)
|
||||
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
|
||||
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
||||
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
|
||||
motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window)
|
||||
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
|
||||
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
||||
tunergenius *tunergenius.Client // Tuner Genius XL (4O3A) ATU (TCP); nil when disabled
|
||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||
spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
|
||||
acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
|
||||
ampsMu sync.Mutex // guards ampInsts
|
||||
ampInsts map[string]*ampInst // one running client per enabled configured amplifier, by config ID
|
||||
audioMgr *audio.Manager
|
||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||
// qsoRecManual marks a take the operator started by hand while automatic
|
||||
// recording is OFF. Such a take opened the sound devices itself, so it must
|
||||
// close them again when it ends — an operator who records one contact does
|
||||
@@ -2608,6 +2615,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
a.maybeAutoSendEQSL(qc)
|
||||
a.maybeSelfSpot(qc)
|
||||
if a.udp != nil {
|
||||
a.udp.EmitLoggedADIF(adif.SingleRecordADIF(qc))
|
||||
}
|
||||
@@ -11584,6 +11592,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
a.maybeAutoSendEQSL(qc)
|
||||
a.maybeSelfSpot(qc)
|
||||
// Forward to the outbound UDP integrations, exactly like the manual log
|
||||
// path — otherwise a QSO logged FROM WSJT-X/JTDX/MSHV was never re-emitted
|
||||
// to the outbound ADIF listeners (Log4OM, N1MM, gridtracker…).
|
||||
@@ -15990,6 +15999,128 @@ func (a *App) GetClusterAutoConnect() (bool, error) {
|
||||
return a.clusterAutoConnect()
|
||||
}
|
||||
|
||||
// ── Self-spot ─────────────────────────────────────────────────────────────
|
||||
|
||||
// selfSpotMinMinutes is the shortest gap OpsLog will announce itself at.
|
||||
//
|
||||
// A self-spot is traffic every user of the node sees, so the floor is a
|
||||
// courtesy rule, not a preference: at one spot per QSO a run would flood the
|
||||
// cluster within minutes and get the station filtered out — which is worse for
|
||||
// the operator than not spotting at all. Five minutes is the interval the
|
||||
// DX-cluster etiquette guides give.
|
||||
const selfSpotMinMinutes = 5
|
||||
|
||||
// selfSpotSameRunHz is how far the frequency may drift and still count as the
|
||||
// same run. A run creeps a few hundred Hz as the operator nudges the VFO, and
|
||||
// re-announcing on every nudge is exactly the flood the gap exists to prevent.
|
||||
// Move further than this and it IS a new frequency, which is announced at once.
|
||||
const selfSpotSameRunHz = 500
|
||||
|
||||
// SelfSpotSettings configures announcing the station on the DX cluster as it
|
||||
// logs, so callers can find it without anyone else having to spot it.
|
||||
type SelfSpotSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Minutes int `json:"minutes"` // shortest gap between two self-spots
|
||||
}
|
||||
|
||||
// GetSelfSpotSettings reads the self-spot configuration.
|
||||
func (a *App) GetSelfSpotSettings() (SelfSpotSettings, error) {
|
||||
out := SelfSpotSettings{Minutes: selfSpotMinMinutes}
|
||||
if a.settings == nil {
|
||||
return out, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyClusterSelfSpot, keyClusterSelfSpotMin)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Enabled = m[keyClusterSelfSpot] == "1"
|
||||
if n, _ := strconv.Atoi(m[keyClusterSelfSpotMin]); n >= selfSpotMinMinutes {
|
||||
out.Minutes = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SaveSelfSpotSettings persists it. The minimum gap is clamped here as well as
|
||||
// in the UI: the floor protects the node from us, so it must not depend on a
|
||||
// frontend that could be bypassed or carry a stale value.
|
||||
func (a *App) SaveSelfSpotSettings(s SelfSpotSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if s.Minutes < selfSpotMinMinutes {
|
||||
s.Minutes = selfSpotMinMinutes
|
||||
}
|
||||
if err := a.settings.Set(a.ctx, keyClusterSelfSpot, boolStr(s.Enabled)); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.Set(a.ctx, keyClusterSelfSpotMin, strconv.Itoa(s.Minutes))
|
||||
}
|
||||
|
||||
// maybeSelfSpot announces the station on the master cluster after a QSO, so a
|
||||
// run is findable without waiting for someone else to spot it.
|
||||
//
|
||||
// The DX call and the spotter are both us: the node fills the "DE" from the
|
||||
// login, and the QSO's own station callsign is the DX — which is the right
|
||||
// source, not the profile, because that is the call the contact was made under.
|
||||
//
|
||||
// Fires on the FIRST QSO of a frequency, then at most once per configured gap.
|
||||
// Called from both log paths (manual entry and UDP auto-log), off the critical
|
||||
// path — a cluster that is slow or down must never hold up logging.
|
||||
func (a *App) maybeSelfSpot(q qso.QSO) {
|
||||
cfg, err := a.GetSelfSpotSettings()
|
||||
if err != nil || !cfg.Enabled {
|
||||
return
|
||||
}
|
||||
call := strings.ToUpper(strings.TrimSpace(q.StationCallsign))
|
||||
if call == "" {
|
||||
return
|
||||
}
|
||||
// The QSO's frequency, not the rig's: by the time this runs the operator may
|
||||
// already have tuned away, and a spot has to say where the contact happened.
|
||||
var hz int64
|
||||
if q.FreqHz != nil {
|
||||
hz = *q.FreqHz
|
||||
}
|
||||
if hz <= 0 && a.cat != nil {
|
||||
hz = a.cat.State().FreqHz // no frequency on the QSO (paper-style entry) — fall back to CAT
|
||||
}
|
||||
if hz <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
a.selfSpotMu.Lock()
|
||||
sameRun := a.selfSpotHz != 0 && absInt64(hz-a.selfSpotHz) <= selfSpotSameRunHz
|
||||
if sameRun && time.Since(a.selfSpotAt) < time.Duration(cfg.Minutes)*time.Minute {
|
||||
a.selfSpotMu.Unlock()
|
||||
return
|
||||
}
|
||||
prevAt, prevHz := a.selfSpotAt, a.selfSpotHz
|
||||
// Claim the slot BEFORE sending: the two log paths can run concurrently and
|
||||
// would otherwise both pass the check and spot twice.
|
||||
a.selfSpotAt, a.selfSpotHz = time.Now(), hz
|
||||
a.selfSpotMu.Unlock()
|
||||
|
||||
khz := math.Round(float64(hz)/100) / 10 // 0.1 kHz — the resolution a cluster keeps
|
||||
if err := a.SendClusterSpot(call, khz, strings.ToUpper(strings.TrimSpace(q.Mode))); err != nil {
|
||||
// Not sent (no enabled server, node not connected). Restore the previous
|
||||
// state so the NEXT QSO tries again, instead of sitting out a gap that
|
||||
// never produced a spot.
|
||||
a.selfSpotMu.Lock()
|
||||
a.selfSpotAt, a.selfSpotHz = prevAt, prevHz
|
||||
a.selfSpotMu.Unlock()
|
||||
applog.Printf("cluster: self-spot %s on %.1f kHz failed: %v", call, khz, err)
|
||||
return
|
||||
}
|
||||
applog.Printf("cluster: self-spot %s on %.1f kHz sent", call, khz)
|
||||
}
|
||||
|
||||
func absInt64(n int64) int64 {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ConnectClusterServer opens a session for one specific saved server.
|
||||
func (a *App) ConnectClusterServer(id int64) error {
|
||||
if a.cluster == nil {
|
||||
|
||||
Reference in New Issue
Block a user