From b4ad12bdc61658f4061877dc8631e8fd41d51557 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Wed, 12 Aug 2026 11:08:45 +0200 Subject: [PATCH] fix(bandopen): the watch switch now gates the detector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Watch for band openings" only ever governed the extra DATA SOURCES — the RBN nodes and the PSK Reporter feed. The detector itself ran on every ordinary cluster spot regardless, so an operator who had never enabled the watch still got 10 m and 6 m opening banners from a feature he had deliberately left off. The flag is cached on bandOpenState 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, and it already runs at startup and on every save, so the switch takes effect without a restart. Switching it off also clears the live badges and the accumulated spot window. The badges only fade on a timer fed by spots the detector no longer looks at, so they would otherwise hang there until the next restart; and dropping the window means switching back on starts from what is on the air rather than from an hour-old burst. The remembered openings are kept — those really happened. --- bandopen_sources.go | 6 ++++ bandopening.go | 33 ++++++++++++++++++++ bandopening_gate_test.go | 65 ++++++++++++++++++++++++++++++++++++++++ changelog.json | 4 +-- 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 bandopening_gate_test.go 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." ] }, {