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" "time" "hamlog/internal/applog" "hamlog/internal/bandopen" "hamlog/internal/cluster" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" ) type bandOpenState struct { 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) { // 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 } // 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 }