The chips in Settings only ever shaped the PSK Reporter SUBSCRIPTION. Neither feed path checked them: both asked bandopen.Watched, which says which bands the detector is capable of and knows nothing about the selection. So the cluster path announced every watched band regardless, and widening the subscription to "+" for grid chasing let PSK Reporter do the same — a station with only 6 m ticked got 10 m and 2 m badges. The selection is now cached beside the on/off flag and checked on both paths, and unticking a band puts its badge out: badges fade on a timer fed by spots the detector no longer looks at, so it would otherwise stay lit until a restart.
231 lines
8.5 KiB
Go
231 lines
8.5 KiB
Go
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
|
|
}
|