diff --git a/bandopen_sources.go b/bandopen_sources.go index 424c439..8dbdd3f 100644 --- a/bandopen_sources.go +++ b/bandopen_sources.go @@ -126,7 +126,13 @@ func (a *App) startBandOpenFeed() { a.pskr = nil } s := a.GetBandOpenSettings() + a.bandOpen.on.Store(s.Enabled) if !s.Enabled { + // 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 + // on a timer fed by spots this path no longer looks at — so they would + // hang there until the app restarted. + a.clearBandOpenings() return } // Every spot is measured from the operator's position. Without one there is diff --git a/bandopening.go b/bandopening.go index 57f3876..d75c71a 100644 --- a/bandopening.go +++ b/bandopening.go @@ -11,6 +11,7 @@ import ( "fmt" "strings" "sync" + "sync/atomic" "time" "hamlog/internal/applog" @@ -21,6 +22,13 @@ import ( ) type bandOpenState struct { + // on mirrors the "Watch for band openings" setting. + // + // Cached rather than read per spot: this is the cluster hot path, where a + // settings query per spot is exactly what the rest of this file avoids. + // startBandOpenFeed owns it — it runs at startup and again on every save, so + // the switch takes effect without a restart. + on atomic.Bool mu sync.Mutex det *bandopen.Detector last []bandopen.Opening // most recent first, for the UI @@ -45,6 +53,16 @@ const maxRememberedOpenings = 20 // detectBandOpening feeds one spot to the detector and announces a hit. func (a *App) detectBandOpening(s cluster.Spot) { + // The watch has to be switched on. + // + // It was not checked here at all: the setting only ever governed the extra + // DATA SOURCES (the RBN nodes and the PSK Reporter feed), while the detector + // 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 + // deliberately left off. + if !a.bandOpen.on.Load() { + return + } // No operator grid = no distance and no bearing on the spot, and the whole // detection rests on those two. Say nothing rather than guess. if !a.opSet || s.DistanceKm <= 0 || !bandopen.Watched(s.Band) { @@ -122,6 +140,21 @@ func (a *App) GetLiveOpenings() []bandopen.Opening { return out } +// 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 +// spots the detector no longer looks at, so without this they would stay up +// until the next restart. The remembered list is left alone — those openings +// really did happen, and the operator may still want to see what he missed. +func (a *App) clearBandOpenings() { + a.bandOpen.mu.Lock() + defer a.bandOpen.mu.Unlock() + a.bandOpen.live = nil + a.bandOpen.aliveUntil = nil + // Drop the accumulated spot window too, so switching the watch back on starts + // from what is on the air now rather than from an hour-old burst. + a.bandOpen.det = nil +} + // announceOpening logs and pushes one detection. Shared by both feeds — the // cluster path here and the PSK Reporter path in bandopen_sources.go — so an // opening reads the same however it was noticed. diff --git a/bandopening_gate_test.go b/bandopening_gate_test.go new file mode 100644 index 0000000..7acb4d7 --- /dev/null +++ b/bandopening_gate_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "testing" + "time" + + "hamlog/internal/bandopen" + "hamlog/internal/cluster" +) + +// The "Watch for band openings" switch has to gate the DETECTOR, not just the +// extra data sources. +// +// It originally governed only the RBN nodes and the PSK Reporter feed, while +// the detector itself ran on every ordinary cluster spot — so an operator who +// had never enabled the watch still got opening banners for a feature he had +// deliberately left off. That is what this pins. +func TestBandOpenWatchGatesTheDetector(t *testing.T) { + spot := func() cluster.Spot { + return cluster.Spot{ + DXCall: "EA1ABC", Band: "6m", DistanceKm: 1400, ShortPath: 210, + ReceivedAt: time.Now(), + } + } + + // Switched off: the spot must not even reach the detector. + off := &App{opSet: true, opLat: 48.0, opLon: 2.0} + off.detectBandOpening(spot()) + if off.bandOpen.det != nil { + t.Error("the detector ran with the watch switched off") + } + + // Switched on: the same spot is accepted (one spot is not an opening, so + // nothing is announced — but the detector now exists and is collecting). + on := &App{opSet: true, opLat: 48.0, opLon: 2.0} + on.bandOpen.on.Store(true) + on.detectBandOpening(spot()) + if on.bandOpen.det == nil { + t.Error("the detector did not run with the watch switched on") + } +} + +// Switching the watch off must put the badges out. They fade on a timer fed by +// spots the detector no longer looks at, so left alone they would stay lit +// until the next restart. +func TestClearBandOpeningsPutsTheBadgesOut(t *testing.T) { + a := &App{} + a.bandOpen.live = map[string]bandopen.Opening{"6m": {Band: "6m", Calls: 9}} + a.bandOpen.aliveUntil = map[string]time.Time{"6m": time.Now().Add(time.Hour)} + a.bandOpen.det = bandopen.New(bandopen.DefaultConfig()) + a.bandOpen.last = []bandopen.Opening{{Band: "6m", Calls: 9}} + + a.clearBandOpenings() + + if got := a.GetLiveOpenings(); len(got) != 0 { + t.Errorf("a badge stayed lit after the watch was switched off: %v", got) + } + if a.bandOpen.det != nil { + t.Error("the accumulated spot window survived — switching back on would start from a stale burst") + } + // The history is NOT cleared: those openings really happened. + if len(a.GetBandOpenings()) != 1 { + t.Error("the remembered openings were thrown away") + } +} diff --git a/changelog.json b/changelog.json index a614bf8..430654e 100644 --- a/changelog.json +++ b/changelog.json @@ -11,7 +11,7 @@ "Motorized antennas: each covered band now has its own tune frequency, set in a box under the band in Settings, and that is where the band button in Station Control sends the antenna. Left empty a band keeps its default, and a frequency that is not in its band is refused rather than sent to the elements.", "Awards: a single award can now be exported on its own, next to the whole-catalogue export. Sharing one award meant handing over your entire catalogue.", "Awards: each reference now has its own validity window, so a reference that ceased to exist counts for QSOs made while it existed and not for later ones. The dates were already stored and were never applied; left empty a reference follows the award's own window.", - "DX cluster: the \"No colour on worked\" and \"Colour unworked here\" options are withdrawn from the filter panel and are off for everyone." + "Band openings: the watch switch now governs the detection itself. It only ever controlled the extra data sources, so opening banners appeared even for operators who had left the option off; switching it off also puts out any badge still lit." ], "fr": [ "Les QSO enregistrés depuis WSJT-X portent désormais la météo spatiale et la distance, comme ceux saisis à la main. Le chemin UDP posait le profil station, le DXCC et les défauts QSL mais ni SFI, ni A, ni K, ni distance — un opérateur en numérique avait donc ces champs vides sur tout son log. La météo spatiale n est posée que sur un contact de moins d un jour : sinon un logiciel qui rediffuse son historique se verrait attribuer les relevés de ce matin sur des contacts du mois dernier.", @@ -22,7 +22,7 @@ "Antennes motorisées : chaque bande couverte a désormais sa propre fréquence d accord, saisie dans une case sous la bande dans les Réglages, et c est là que le bouton de bande du Contrôle station envoie l antenne. Laissée vide, une bande garde son défaut, et une fréquence hors de sa bande est refusée plutôt qu envoyée aux éléments.", "Diplômes : un diplôme peut désormais être exporté seul, à côté de l export du catalogue complet. Partager un seul diplôme obligeait à livrer tout son catalogue.", "Diplômes : chaque référence a désormais sa propre fenêtre de validité, si bien qu une référence qui a cessé d exister compte pour les QSO faits de son vivant et pas pour les suivants. Les dates étaient déjà stockées et n étaient jamais appliquées ; laissée vide, une référence suit la fenêtre du diplôme.", - "Cluster DX : les options « Pas de couleur sur les faits » et « Colorer les non faits ici » sont retirées du panneau de filtres et désactivées pour tout le monde." + "Ouvertures de bande : l interrupteur de la veille gouverne désormais la détection elle-même. Il ne pilotait que les sources de données supplémentaires, si bien que les bandeaux d ouverture apparaissaient même chez les opérateurs qui avaient laissé l option désactivée ; la désactiver éteint aussi les badges encore allumés." ] }, {