Files
OpsLog/bandopening.go
T
rouggy 96c99f0ae6 feat(bandopen): a blinking badge that lasts as long as the opening does
The only sign of a detection was a toast. It is gone in seconds, and an operator
who was tuning at that moment had no way back to it — for an event that lasts
hours and happens a handful of times a season, that is the wrong shape entirely.

The badge sits at the right-hand end of the status bar, before the clock: an
opening is a state of the WORLD, not of this station, so it belongs with the
time and the logbook rather than among the rig and amplifier chips.

Knowing when to go out was the real work. Nothing announces that an opening
ended, and the detector deliberately says nothing more about a band for 45
minutes after announcing it — right for a message, useless for a badge. So it is
inferred: every qualifying spot on that band pushes a deadline out, and when
they stop arriving the badge fades by itself. Fifteen minutes, comfortably more
than the detector's own twelve-minute window, so a quiet couple of minutes
mid-opening does not blink it off and on again.

Gated on the same distance floor the detector uses. Without that, a band busy
with short-range tropo would hold an Es badge lit indefinitely on spots the
detector itself had refused.
2026-08-11 13:48:26 +02:00

156 lines
5.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"
"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
}