package main // Band-opening announcements — the app-side glue for internal/bandopen. // // The detector needs nothing OpsLog does not already compute: the cluster event // worker enriches every spot with the great-circle distance and bearing from // the operator's grid before this is called. So watching for sporadic E costs // one function call per spot and no new data source. import ( "fmt" "strings" "sync" "sync/atomic" "time" "hamlog/internal/applog" "hamlog/internal/bandopen" "hamlog/internal/cluster" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) 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 // 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 det *bandopen.Detector last []bandopen.Opening // most recent first, for the UI // live holds the announced openings that are still going, keyed by band, and // aliveUntil says when each stops counting as current. // // The detector announces an opening ONCE and then goes quiet for 45 minutes, // which is right for a message but useless for a badge that has to stay lit // while the band is open and go out when it closes. Nothing tells us an // opening ended, so it is inferred: every qualifying spot on that band pushes // the deadline out, and when they stop arriving the badge fades by itself. live map[string]bandopen.Opening aliveUntil map[string]time.Time } // openingIdle is how long a band may go without a qualifying spot before its // badge goes out. Longer than the detector's own 12-minute window, so a quiet // couple of minutes mid-opening does not blink the badge off and on again. const openingIdle = 15 * time.Minute 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() || !a.bandOpenWanted(s.Band) { 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) { return } a.bandOpen.mu.Lock() if a.bandOpen.det == nil { a.bandOpen.det = bandopen.New(bandopen.DefaultConfig()) } op := a.bandOpen.det.Add(bandopen.Spot{ Call: s.DXCall, Band: s.Band, DistKm: s.DistanceKm, Bearing: s.ShortPath, At: s.ReceivedAt, }, a.opLat) if op != nil { a.rememberOpening(*op) } // Keeps a lit badge lit. Gated on the same floor the detector uses, or a // band busy with short-range tropo would hold an Es badge on for ever. if s.DistanceKm >= bandopen.DefaultConfig().MinKm { a.markBandAlive(s.Band, s.ReceivedAt) } a.bandOpen.mu.Unlock() if op != nil { a.announceOpening(*op) } } // markBandAlive pushes a band's badge deadline out. Called for every spot the // detector accepted, from either feed. Cheap on purpose: this runs on the MQTT // goroutine at thousands a minute when 6 m is open. // // Caller holds bandOpen.mu. func (a *App) markBandAlive(band string, at time.Time) { if _, lit := a.bandOpen.live[strings.ToLower(band)]; !lit { return // nothing announced for this band, nothing to keep alive } if a.bandOpen.aliveUntil == nil { a.bandOpen.aliveUntil = map[string]time.Time{} } a.bandOpen.aliveUntil[strings.ToLower(band)] = at.Add(openingIdle) } // rememberOpening files a detection and lights its badge. Caller holds the mutex. func (a *App) rememberOpening(op bandopen.Opening) { a.bandOpen.last = append([]bandopen.Opening{op}, a.bandOpen.last...) if len(a.bandOpen.last) > maxRememberedOpenings { a.bandOpen.last = a.bandOpen.last[:maxRememberedOpenings] } if a.bandOpen.live == nil { a.bandOpen.live = map[string]bandopen.Opening{} a.bandOpen.aliveUntil = map[string]time.Time{} } b := strings.ToLower(op.Band) a.bandOpen.live[b] = op a.bandOpen.aliveUntil[b] = op.At.Add(openingIdle) } // GetLiveOpenings returns the openings still under way, for the status-bar // badge. Expired ones are dropped as they are noticed — there is no janitor for // something that holds at most five entries. func (a *App) GetLiveOpenings() []bandopen.Opening { now := time.Now() a.bandOpen.mu.Lock() defer a.bandOpen.mu.Unlock() out := make([]bandopen.Opening, 0, len(a.bandOpen.live)) for b, op := range a.bandOpen.live { if until, ok := a.bandOpen.aliveUntil[b]; !ok || now.After(until) { delete(a.bandOpen.live, b) delete(a.bandOpen.aliveUntil, b) applog.Printf("bandopen: %s opening has gone quiet", strings.ToUpper(b)) continue } out = append(out, op) } 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. // 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. func (a *App) announceOpening(op bandopen.Opening) { applog.Printf("bandopen: %s opening — %d stations, ~%d km, %s%s (%s)", op.Band, op.Calls, op.MedianKm, op.Sector(), map[bool]string{true: "", false: " — UNUSUAL for the season"}[op.InSeason], strings.Join(op.Examples, " ")) if a.ctx != nil { wruntime.EventsEmit(a.ctx, "bandopen:detected", op) } } // GetBandOpenings returns the openings seen this session, newest first. The UI // polls this so a detection is still visible after its toast has gone. func (a *App) GetBandOpenings() []bandopen.Opening { a.bandOpen.mu.Lock() defer a.bandOpen.mu.Unlock() out := make([]bandopen.Opening, len(a.bandOpen.last)) copy(out, a.bandOpen.last) return out } // BandOpeningSummary is the one-line form used in the toast and the log. func BandOpeningSummary(o bandopen.Opening) string { s := fmt.Sprintf("%s open — %d stations ~%d km, %s", strings.ToUpper(o.Band), o.Calls, o.MedianKm, o.Sector()) if !o.InSeason { s += " (unusual for the season)" } return s }