fix(bandopen): honour the operator's band selection

The chips in Settings only ever shaped the PSK Reporter SUBSCRIPTION. Neither
feed path checked them: both asked bandopen.Watched, which says which bands the
detector is capable of and knows nothing about the selection. So the cluster
path announced every watched band regardless, and widening the subscription to
"+" for grid chasing let PSK Reporter do the same — a station with only 6 m
ticked got 10 m and 2 m badges.

The selection is now cached beside the on/off flag and checked on both paths,
and unticking a band puts its badge out: badges fade on a timer fed by spots
the detector no longer looks at, so it would otherwise stay lit until a
restart.
This commit is contained in:
2026-08-12 17:25:16 +02:00
parent 7d33379fe1
commit 4f3c3edaee
4 changed files with 105 additions and 7 deletions
+3 -2
View File
@@ -20,9 +20,9 @@ import (
"hamlog/internal/applog" "hamlog/internal/applog"
"hamlog/internal/bandopen" "hamlog/internal/bandopen"
"hamlog/internal/cluster"
"hamlog/internal/geo" "hamlog/internal/geo"
"hamlog/internal/gridcache" "hamlog/internal/gridcache"
"hamlog/internal/cluster"
"hamlog/internal/pskr" "hamlog/internal/pskr"
) )
@@ -129,6 +129,7 @@ func (a *App) startBandOpenFeed() {
} }
s := a.GetBandOpenSettings() s := a.GetBandOpenSettings()
a.bandOpen.on.Store(s.Enabled) a.bandOpen.on.Store(s.Enabled)
a.setBandOpenBands(s.Bands)
if !s.Enabled { if !s.Enabled {
// Put out whatever is currently lit. Leaving the badges up would keep // Put out whatever is currently lit. Leaving the badges up would keep
// announcing an opening from a watch that is now off, and they only fade // announcing an opening from a watch that is now off, and they only fade
@@ -204,7 +205,7 @@ func (a *App) startBandOpenFeed() {
// so it does the least possible: the detector's own window and de-duplication // so it does the least possible: the detector's own window and de-duplication
// by callsign are what turn that flood into one announcement. // by callsign are what turn that flood into one announcement.
func (a *App) feedBandOpen(s pskr.Spot) { func (a *App) feedBandOpen(s pskr.Spot) {
if !bandopen.Watched(s.Band) { if !a.bandOpenWanted(s.Band) {
return return
} }
a.bandOpen.mu.Lock() a.bandOpen.mu.Lock()
+43 -1
View File
@@ -29,6 +29,13 @@ type bandOpenState struct {
// startBandOpenFeed owns it — it runs at startup and again on every save, so // startBandOpenFeed owns it — it runs at startup and again on every save, so
// the switch takes effect without a restart. // the switch takes effect without a restart.
on atomic.Bool on atomic.Bool
// bands is the operator's SELECTED set, held as map[string]bool.
//
// bandopen.Watched only says which bands the detector is capable of; it
// knows nothing about the chips in Settings. Nothing checked the selection
// on either feed path, so unticking 10 m and 2 m changed the subscription and
// left the cluster path announcing them anyway.
bands atomic.Value
mu sync.Mutex mu sync.Mutex
det *bandopen.Detector det *bandopen.Detector
last []bandopen.Opening // most recent first, for the UI last []bandopen.Opening // most recent first, for the UI
@@ -60,7 +67,7 @@ func (a *App) detectBandOpening(s cluster.Spot) {
// itself ran on every ordinary cluster spot. So an operator who had never // itself ran on every ordinary cluster spot. So an operator who had never
// enabled the watch still got opening banners, from a feature they had // enabled the watch still got opening banners, from a feature they had
// deliberately left off. // deliberately left off.
if !a.bandOpen.on.Load() { if !a.bandOpen.on.Load() || !a.bandOpenWanted(s.Band) {
return return
} }
// No operator grid = no distance and no bearing on the spot, and the whole // No operator grid = no distance and no bearing on the spot, and the whole
@@ -140,6 +147,41 @@ func (a *App) GetLiveOpenings() []bandopen.Opening {
return out return out
} }
// bandOpenWanted reports whether the operator has this band ticked. Falls back
// to the detector's own set until a selection has been stored, so a band is
// never silently dropped before the settings have been read.
func (a *App) bandOpenWanted(band string) bool {
if !bandopen.Watched(band) {
return false
}
sel, ok := a.bandOpen.bands.Load().(map[string]bool)
if !ok || len(sel) == 0 {
return true
}
return sel[strings.ToLower(strings.TrimSpace(band))]
}
// setBandOpenBands records the selection and puts out badges for bands that
// have just been unticked — they fade on a timer fed by spots the detector no
// longer looks at, so they would otherwise stay lit until the next restart.
func (a *App) setBandOpenBands(bands []string) {
sel := make(map[string]bool, len(bands))
for _, b := range bands {
sel[strings.ToLower(strings.TrimSpace(b))] = true
}
a.bandOpen.bands.Store(sel)
a.bandOpen.mu.Lock()
defer a.bandOpen.mu.Unlock()
for b := range a.bandOpen.live {
if len(sel) > 0 && !sel[b] {
delete(a.bandOpen.live, b)
delete(a.bandOpen.aliveUntil, b)
applog.Printf("bandopen: %s is no longer watched — badge cleared", strings.ToUpper(b))
}
}
}
// clearBandOpenings puts out every lit badge and forgets the detector's window. // clearBandOpenings puts out every lit badge and forgets the detector's window.
// Called when the watch is switched off: the badges fade on a timer fed by // Called when the watch is switched off: the badges fade on a timer fed by
// spots the detector no longer looks at, so without this they would stay up // spots the detector no longer looks at, so without this they would stay up
+53
View File
@@ -63,3 +63,56 @@ func TestClearBandOpeningsPutsTheBadgesOut(t *testing.T) {
t.Error("the remembered openings were thrown away") t.Error("the remembered openings were thrown away")
} }
} }
// Unticking a band in Settings must actually stop its announcements.
//
// Nothing checked the operator's selection on either feed path: bandopen.Watched
// only says which bands the detector is CAPABLE of. So a station with just 6 m
// ticked still got 10 m and 2 m badges, from the cluster path and — once grid
// chasing widened the subscription to every band — from PSK Reporter too.
func TestBandOpenHonoursTheSelectedBands(t *testing.T) {
a := &App{opSet: true, opLat: 48, opLon: 2}
a.bandOpen.on.Store(true)
a.setBandOpenBands([]string{"6m"})
if !a.bandOpenWanted("6m") {
t.Error("6m is ticked and was refused")
}
for _, b := range []string{"10m", "2m", "4m"} {
if a.bandOpenWanted(b) {
t.Errorf("%s is not ticked and was accepted", b)
}
}
// A band the detector cannot watch at all stays out whatever is stored.
if a.bandOpenWanted("20m") {
t.Error("20m is not a watched band and was accepted")
}
// And the cluster path must obey it too.
a.detectBandOpening(cluster.Spot{
DXCall: "K1ABC", Band: "10m", DistanceKm: 6000, ShortPath: 280, ReceivedAt: time.Now(),
})
if a.bandOpen.det != nil {
t.Error("a spot on an unticked band reached the detector")
}
}
// Unticking a band puts its badge out. They fade on a timer fed by spots the
// detector no longer looks at, so it would otherwise stay lit until a restart.
func TestUntickingABandClearsItsBadge(t *testing.T) {
a := &App{}
a.bandOpen.live = map[string]bandopen.Opening{
"6m": {Band: "6m", Calls: 9},
"10m": {Band: "10m", Calls: 5},
}
a.bandOpen.aliveUntil = map[string]time.Time{
"6m": time.Now().Add(time.Hour),
"10m": time.Now().Add(time.Hour),
}
a.setBandOpenBands([]string{"6m"})
live := a.GetLiveOpenings()
if len(live) != 1 || live[0].Band != "6m" {
t.Errorf("live openings = %v, want only 6m", live)
}
}
+4 -2
View File
@@ -5,12 +5,14 @@
"en": [ "en": [
"Cluster: the grid cache now holds 100,000 callsigns and rotates instead of emptying itself, so locators stop vanishing from the list.", "Cluster: the grid cache now holds 100,000 callsigns and rotates instead of emptying itself, so locators stop vanishing from the list.",
"New option \"Chase new grids\": locators learnt from your decodes and from PSK Reporter are kept in their own database, with their source.", "New option \"Chase new grids\": locators learnt from your decodes and from PSK Reporter are kept in their own database, with their source.",
"Band openings: the PSK Reporter feed is now filtered at the broker, which cuts it from about 83 messages a second to under two." "Band openings: the PSK Reporter feed is now filtered at the broker, which cuts it from about 83 messages a second to under two.",
"Band openings: unticking a band now actually stops its announcements, and puts its badge out."
], ],
"fr": [ "fr": [
"Cluster : le cache de locators garde 100 000 indicatifs et tourne au lieu de se vider, les locators ne disparaissent donc plus de la liste.", "Cluster : le cache de locators garde 100 000 indicatifs et tourne au lieu de se vider, les locators ne disparaissent donc plus de la liste.",
"Nouvelle option « Chasse aux nouveaux carrés » : les locators appris de tes décodes et de PSK Reporter sont conservés dans leur propre base, avec leur source.", "Nouvelle option « Chasse aux nouveaux carrés » : les locators appris de tes décodes et de PSK Reporter sont conservés dans leur propre base, avec leur source.",
"Ouvertures de bande : le flux PSK Reporter est désormais filtré chez le broker, ce qui le fait passer d environ 83 messages par seconde à moins de deux." "Ouvertures de bande : le flux PSK Reporter est désormais filtré chez le broker, ce qui le fait passer d environ 83 messages par seconde à moins de deux.",
"Ouvertures de bande : décocher une bande arrête réellement ses annonces et éteint son badge."
] ]
}, },
{ {