"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.
189 lines
7.0 KiB
Go
189 lines
7.0 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
|
|
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() {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|