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.
This commit is contained in:
+72
-4
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
"hamlog/internal/bandopen"
|
||||
@@ -23,8 +24,23 @@ 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.
|
||||
@@ -43,10 +59,12 @@ func (a *App) detectBandOpening(s cluster.Spot) {
|
||||
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.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 {
|
||||
@@ -54,6 +72,56 @@ func (a *App) detectBandOpening(s cluster.Spot) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user