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:
2026-08-08 20:34:10 +02:00
parent 90c6458af0
commit 1f55cfe9fa
7 changed files with 236 additions and 30 deletions
+157 -26
View File
@@ -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 {