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" "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 } 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.bandOpen.last = append([]bandopen.Opening{*op}, a.bandOpen.last...) if len(a.bandOpen.last) > maxRememberedOpenings { a.bandOpen.last = a.bandOpen.last[:maxRememberedOpenings] } } a.bandOpen.mu.Unlock() if op != nil { a.announceOpening(*op) } } // 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 }