feat(flex): send SmartSDR's spot priority; fix(chase): the same station on the same slot has nothing left to give

The panadapter has finite room: spots close in frequency are stacked
behind a '+' and only one is drawn, chosen by PRIORITY — a parameter
'spot add' accepts and OpsLog never sent. So a new entity sat invisible
behind three stations already in the log. DXHunter has sent it for
years; the tiers here are the same idea in the operator's own words:
the entity never worked (with my own callsign, for a multi-op), then
band/mode/slot, then the reference hunts, then everything else.

And a callsign already worked on this exact band and mode stops
advertising a need. Working it again cannot turn a missing QSL into a
confirmation — the QSO is already there — so shouting NEW DXCC over a
station worked an hour ago only teaches an operator to distrust the
colour. The need is real and stays on every OTHER station of the entity,
which is where it can be answered. Applied on both paths out of the
verdict, including the early one that leaves the loop first.
This commit is contained in:
2026-09-01 23:33:56 +02:00
parent 4d8cb58550
commit 4c4b3b6c2d
6 changed files with 111 additions and 3 deletions
+23
View File
@@ -9153,6 +9153,7 @@ func (a *App) clusterEventWorker() {
FreqHz: s.FreqHz, FreqHz: s.FreqHz,
Callsign: s.DXCall, Callsign: s.DXCall,
Mode: mode, Mode: mode,
Priority: spotPriority(status),
Comment: spotComment(s.Comment, s.Spotter, s.Country, status), Comment: spotComment(s.Comment, s.Spotter, s.Country, status),
Color: col.Text, Color: col.Text,
BackgroundColor: col.Bg, BackgroundColor: col.Bg,
@@ -14398,6 +14399,7 @@ func (a *App) consumeUDPEvents() {
BackgroundColor: bgCol, BackgroundColor: bgCol,
Comment: spotComment(fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), "", a.countryFor(ev.DecodeCall), status), Comment: spotComment(fmt.Sprintf("%s %+ddB", ev.Mode, ev.DecodeSNR), "", a.countryFor(ev.DecodeCall), status),
LifetimeSec: secs, LifetimeSec: secs,
Priority: spotPriority(status),
}: }:
default: default:
// Radio not keeping up: skip the spot rather than stall the // Radio not keeping up: skip the spot rather than stall the
@@ -21043,6 +21045,12 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if !worked { if !worked {
out[i].Status = "new" out[i].Status = "new"
if _, ever := entities[dxccNum]; ever { if _, ever := entities[dxccNum]; ever {
// Worked, never confirmed — and if it was THIS station on THIS
// slot, the need is not its to answer (see the rule below).
if out[i].WorkedSlot {
out[i].Status = "worked"
continue
}
out[i].UnconfStatus = true out[i].UnconfStatus = true
} }
continue continue
@@ -21079,6 +21087,21 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
} }
} }
} }
// THIS station, already in the log on THIS slot, has nothing left to
// give. Working it a second time on the same band and mode cannot make
// the entity count: the QSO is already there and only the QSL is
// missing, and a duplicate does not produce one. So the need is real —
// it is simply not this station's to answer, and shouting NEW DXCC over
// a callsign worked an hour ago is how an operator learns to distrust
// the colour. Another station in the entity still carries the badge.
//
// Only for a need that IS a missing confirmation: a genuinely new
// entity cannot co-exist with a worked callsign, so nothing real is
// suppressed here.
if out[i].UnconfStatus && out[i].WorkedSlot {
out[i].Status = "worked"
out[i].UnconfStatus = false
}
} }
return out return out
} }
+6 -2
View File
@@ -15,7 +15,9 @@
"New DXHunter page in Preferences: every Chase setting moves there from the DX Cluster page — the hunt, its confirmation sources, and the POTA / SOTA / US counties / prefixes / grids switches. They govern three screens now, so they no longer live under the name of one of them; more of DXHunters ideas will land beside them.", "New DXHunter page in Preferences: every Chase setting moves there from the DX Cluster page — the hunt, its confirmation sources, and the POTA / SOTA / US counties / prefixes / grids switches. They govern three screens now, so they no longer live under the name of one of them; more of DXHunters ideas will land beside them.",
"Chase US states joins the other switches: unchecked, the NEW STATE badge and its filter chip go quiet everywhere.", "Chase US states joins the other switches: unchecked, the NEW STATE badge and its filter chip go quiet everywhere.",
"FT Map: zooming no longer leaves a dead strip along the bottom. Leaflet measures its container once, when the map is created — which here is the instant the tab is selected, before the layout has settled — and it is now told whenever that box changes size.", "FT Map: zooming no longer leaves a dead strip along the bottom. Leaflet measures its container once, when the map is created — which here is the instant the tab is selected, before the layout has settled — and it is now told whenever that box changes size.",
"FT decodes: the continent filter shows all seven, always, instead of only those currently on the feed — the row no longer reshuffles under the pointer when the first Asian station decodes, and it says what the filter can do before anything has been heard." "FT decodes: the continent filter shows all seven, always, instead of only those currently on the feed — the row no longer reshuffles under the pointer when the first Asian station decodes, and it says what the filter can do before anything has been heard.",
"Panadapter spots now carry SmartSDRs priority: an entity never worked comes first, then the band / mode / slot needs, then POTA, SOTA, county and prefix, and everything else last. The radio stacks spots that sit close in frequency behind a “+” and draws only one — it picks by priority, so a new DXCC was disappearing behind stations already in the log simply because OpsLog never said which was worth the space.",
"A station already worked on the SAME band and mode no longer advertises a need. Working it a second time cannot turn a missing QSL into a confirmation, so the badge goes quiet on that callsign — and stays on every other station of the entity, which is where the need can actually be answered."
], ],
"fr": [ "fr": [
"DX Cluster : un serveur déconnecté garde sa pastille et peut donc être reconnecté — le déconnecter le faisait disparaître avec le seul moyen dy revenir.", "DX Cluster : un serveur déconnecté garde sa pastille et peut donc être reconnecté — le déconnecter le faisait disparaître avec le seul moyen dy revenir.",
@@ -30,7 +32,9 @@
"Nouvelle page DXHunter dans les Préférences : tous les réglages de chasse y déménagent depuis la page DX Cluster — le mode de chasse, ses sources de confirmation et les cases POTA / SOTA / comtés US / préfixes / locators. Ils commandent trois écrans, ils ne vivent donc plus sous le nom dun seul ; dautres idées de DXHunter viendront sy ajouter.", "Nouvelle page DXHunter dans les Préférences : tous les réglages de chasse y déménagent depuis la page DX Cluster — le mode de chasse, ses sources de confirmation et les cases POTA / SOTA / comtés US / préfixes / locators. Ils commandent trois écrans, ils ne vivent donc plus sous le nom dun seul ; dautres idées de DXHunter viendront sy ajouter.",
"Chasser les états US rejoint les autres cases : décochée, le badge NOUVEL ÉTAT et sa puce de filtre se taisent partout.", "Chasser les états US rejoint les autres cases : décochée, le badge NOUVEL ÉTAT et sa puce de filtre se taisent partout.",
"FT Map : le zoom ne laisse plus une bande morte en bas. Leaflet mesure son conteneur une seule fois, à la création de la carte — ici linstant où longlet est sélectionné, avant que la mise en page ne se soit stabilisée — et il est désormais prévenu à chaque changement de taille.", "FT Map : le zoom ne laisse plus une bande morte en bas. Leaflet mesure son conteneur une seule fois, à la création de la carte — ici linstant où longlet est sélectionné, avant que la mise en page ne se soit stabilisée — et il est désormais prévenu à chaque changement de taille.",
"FT decodes : le filtre continent affiche les sept, toujours, au lieu des seuls présents dans le flux — la rangée ne se réorganise plus sous le pointeur quand la première station asiatique décode, et elle annonce ce que le filtre sait faire avant même davoir entendu quoi que ce soit." "FT decodes : le filtre continent affiche les sept, toujours, au lieu des seuls présents dans le flux — la rangée ne se réorganise plus sous le pointeur quand la première station asiatique décode, et elle annonce ce que le filtre sait faire avant même davoir entendu quoi que ce soit.",
"Les spots du panadapter portent désormais la priorité SmartSDR : une entité jamais contactée dabord, puis les besoins bande / mode / slot, puis POTA, SOTA, comté et préfixe, et le reste en dernier. La radio empile les spots proches en fréquence derrière un « + » et nen dessine quun — elle choisit par priorité, si bien quun nouveau DXCC disparaissait derrière des stations déjà au log, faute pour OpsLog davoir dit laquelle méritait la place.",
"Une station déjà contactée sur la MÊME bande et le même mode nannonce plus de besoin. La recontacter ne transformera pas une QSL manquante en confirmation : le badge se tait sur cet indicatif — et reste sur toutes les autres stations de lentité, là où le besoin peut réellement être comblé."
] ]
}, },
{ {
+8
View File
@@ -304,6 +304,14 @@ type SpotInfo struct {
BackgroundColor string BackgroundColor string
Comment string Comment string
LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default) LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default)
// Priority is SmartSDR's own tie-breaker, 1 (highest) to 5.
//
// It matters because the panadapter has finite room: spots close in
// frequency are stacked behind a "+" and only one of them is drawn. The
// radio picks that one by priority — so an entity never worked can sit
// invisible behind three stations already in the log unless we say which
// is worth the space. 0 leaves the field off the command entirely.
Priority int
} }
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio // Spotter is an OPTIONAL backend capability: show cluster spots on the radio
+5
View File
@@ -1557,6 +1557,10 @@ func (f *Flex) SendSpot(s SpotInfo) error {
if hadOld { if hadOld {
f.send(fmt.Sprintf("spot remove %d", old)) f.send(fmt.Sprintf("spot remove %d", old))
} }
prio := ""
if s.Priority >= 1 && s.Priority <= 5 {
prio = fmt.Sprintf(" priority=%d", s.Priority)
}
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d", cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix()) float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
// Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the // Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the
@@ -1575,6 +1579,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
if c := flexEncode(s.Comment); c != "" { if c := flexEncode(s.Comment); c != "" {
cmd += " comment=" + c cmd += " comment=" + c
} }
cmd += prio
seq := f.send(cmd) seq := f.send(cmd)
if seq > 0 { if seq > 0 {
// Remember which call this add was for; the R<seq> response carries the // Remember which call this add was for; the R<seq> response carries the
+24
View File
@@ -314,6 +314,30 @@ func bracket(s string) string {
// //
// Read from the same cached palette as the colours — see spotColorFor — because // Read from the same cached palette as the colours — see spotColorFor — because
// it is consulted on every spot from every cluster. // it is consulted on every spot from every cluster.
// spotPriority ranks a spot for SmartSDR's own tie-breaker, 1 (highest) to 5.
//
// The panadapter has finite room: spots close in frequency are stacked behind a
// "+" and only one of them is drawn. The radio chooses by priority — so an
// entity never worked was disappearing behind three stations already in the
// log, simply because OpsLog never said which was worth the space.
//
// The tiers are the operator's own: the entity never worked first, then the
// pieces of one already worked (band, mode, slot), then the reference hunts
// (POTA, SOTA, county, prefix), and everything else last. My own callsign rides
// at the top with the first — it is how a multi-op sees where it already is.
func spotPriority(status string) int {
switch status {
case "new", "my-call":
return 1
case "new-band-mode", "new-band", "new-mode", "new-slot":
return 2
case "new-pota", "new-sota", "new-county", "new-pfx":
return 3
default:
return 5
}
}
func (a *App) spotHidden(status string) bool { func (a *App) spotHidden(status string) bool {
a.spotColorsMu.Lock() a.spotColorsMu.Lock()
if a.spotColorsCache == nil { if a.spotColorsCache == nil {
+45 -1
View File
@@ -57,7 +57,9 @@ func TestClusterStatusFlagsUnconfirmedBand(t *testing.T) {
add("ZS6GAV", "15m", "FT8", "N") // 15m worked, never confirmed add("ZS6GAV", "15m", "FT8", "N") // 15m worked, never confirmed
add("ZS4AW", "15m", "FT8", "N") add("ZS4AW", "15m", "FT8", "N")
got := a.ClusterSpotStatuses([]SpotQuery{{Call: "ZS4AW", Band: "15m", Mode: "FT8"}}) // A station of the entity that is NOT the one already worked on this slot:
// that case has a rule of its own, tested below.
got := a.ClusterSpotStatuses([]SpotQuery{{Call: "ZS9XX", Band: "15m", Mode: "FT8"}})
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("got %d results", len(got)) t.Fatalf("got %d results", len(got))
} }
@@ -68,3 +70,45 @@ func TestClusterStatusFlagsUnconfirmedBand(t *testing.T) {
t.Error("UnconfStatus is false — the badge draws solid, as if 15m had never been worked") t.Error("UnconfStatus is false — the badge draws solid, as if 15m had never been worked")
} }
} }
// The same station, already worked on the same band and mode, has nothing left
// to give: working it again cannot turn a missing QSL into a confirmation. The
// need belongs to the entity, not to that callsign, so the badge goes quiet on
// it — and stays on every other station of the entity.
func TestWorkedSameSlotDropsTheUnconfirmedBadge(t *testing.T) {
dir := t.TempDir()
conn, err := db.Open(filepath.Join(dir, "log.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { conn.Close() })
a := &App{ctx: context.Background(), qso: qso.NewRepo(conn), settings: settings.NewStore(conn)}
a.settingsScoped.Store(true)
_ = a.settings.Set(a.ctx, keyChaseMode, "new_unconfirmed")
_ = a.settings.Set(a.ctx, keyChaseConfirm, "lotw,card")
a.dxcc = dxcc.NewManager(filepath.Join("build", "bin", "data"))
if err := a.dxcc.LoadFromDisk(); err != nil {
t.Skipf("cty.dat not available here: %v", err)
}
hz := int64(10136000)
if _, err := a.qso.Add(a.ctx, qso.QSO{
Callsign: "TN8GD", Band: "30m", Mode: "FT8", FreqHz: &hz,
QSODate: time.Now().UTC().Add(-24 * time.Hour),
}); err != nil {
t.Fatalf("add: %v", err)
}
got := a.ClusterSpotStatuses([]SpotQuery{
{Call: "TN8GD", Band: "30m", Mode: "FT8"}, // the very station worked
{Call: "TN4XY", Band: "30m", Mode: "FT8"}, // another in the same entity
})
if got[0].Status == "new" || got[0].UnconfStatus {
t.Errorf("the worked station still advertises a need: status=%q unconf=%v",
got[0].Status, got[0].UnconfStatus)
}
if got[1].Status != "new" {
t.Errorf("another station in the entity lost its badge: status=%q", got[1].Status)
}
}