Files
OpsLog/bandopen_sources.go
T
rouggy b4ad12bdc6 fix(bandopen): the watch switch now gates the detector
"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.
2026-08-12 11:08:45 +02:00

226 lines
7.1 KiB
Go

package main
// The data sources band-opening detection depends on, and the settings that
// switch them on.
//
// The detection shipped reading whatever the operator's cluster nodes happened
// to carry. That was a design mistake of the worst kind: the feature depended
// on RBN feeds and a PSK Reporter subscription that nobody could know were
// needed, so it looked broken rather than unconfigured. Nexus showed a 6 m
// opening with 869 stations while OpsLog, on the same PC, showed nothing.
//
// So enabling the watch ARRANGES ITS OWN SOURCES: it adds the two RBN nodes if
// they are missing and brings the PSK Reporter feed up. Turning it off leaves
// the nodes alone — they may have been wanted for their own sake, and silently
// removing a cluster node an operator is using would be worse than leaving one
// they no longer need.
import (
"strings"
"hamlog/internal/applog"
"hamlog/internal/bandopen"
"hamlog/internal/cluster"
"hamlog/internal/pskr"
)
const (
keyBandOpenEnabled = "bandopen.enabled"
keyBandOpenBands = "bandopen.bands" // comma-separated; empty = the default set
)
// rbnNodes are the two Reverse Beacon Network endpoints the watch wants: CW and
// digital are separate ports and carry different skimmers.
var rbnNodes = []cluster.ServerConfig{
{Name: "RBN CW", Host: "telnet.reversebeacon.net", Port: 7000, Enabled: true},
{Name: "RBN FTx", Host: "telnet.reversebeacon.net", Port: 7001, Enabled: true},
}
// BandOpenSettings is the panel's shape.
type BandOpenSettings struct {
Enabled bool `json:"enabled"`
Bands []string `json:"bands"`
// Available is every band that can be watched, so the UI does not carry its
// own copy of a list that belongs to the detector.
Available []string `json:"available"`
}
func (a *App) GetBandOpenSettings() BandOpenSettings {
s := BandOpenSettings{
Enabled: a.settingOr(keyBandOpenEnabled, "") == "1",
Bands: splitCSV(a.settingOr(keyBandOpenBands, "")),
Available: pskr.Bands,
}
// Keep only bands that are still offered. A saved selection outlives the code
// that made it: 12 m was dropped from the watched set, but every operator who
// had already enabled the watch kept subscribing to it — paying for a firehose
// whose messages the detector then threw away.
s.Bands = keepKnownBands(s.Bands)
if len(s.Bands) == 0 {
s.Bands = append(s.Bands, pskr.Bands...)
}
return s
}
func keepKnownBands(want []string) []string {
ok := make(map[string]bool, len(pskr.Bands))
for _, b := range pskr.Bands {
ok[b] = true
}
out := make([]string, 0, len(want))
for _, b := range want {
if ok[strings.ToLower(strings.TrimSpace(b))] {
out = append(out, b)
}
}
return out
}
func (a *App) SaveBandOpenSettings(s BandOpenSettings) error {
a.setSetting(keyBandOpenEnabled, map[bool]string{true: "1", false: "0"}[s.Enabled])
a.setSetting(keyBandOpenBands, strings.Join(s.Bands, ","))
if s.Enabled {
a.ensureRBNNodes()
}
a.startBandOpenFeed()
return nil
}
// ensureRBNNodes adds the RBN endpoints when they are absent.
//
// Matched on host AND port rather than on name: an operator who renamed theirs
// "Skimmers CW" has the node, and adding a second one pointed at the same
// server would give them every spot twice — which the detector would read as
// twice as many stations, i.e. an opening that is not there.
func (a *App) ensureRBNNodes() {
have, err := a.ListClusterServers()
if err != nil {
applog.Printf("bandopen: cannot read the cluster nodes (%v) — not adding RBN", err)
return
}
for _, want := range rbnNodes {
found := false
for _, h := range have {
if strings.EqualFold(strings.TrimSpace(h.Host), want.Host) && h.Port == want.Port {
found = true
break
}
}
if found {
continue
}
if _, err := a.SaveClusterServer(want); err != nil {
applog.Printf("bandopen: could not add %s: %v", want.Name, err)
continue
}
applog.Printf("bandopen: added cluster node %s (%s:%d) — the watch needs it",
want.Name, want.Host, want.Port)
}
}
// startBandOpenFeed brings the PSK Reporter subscription up or down to match
// the setting. Called at startup and whenever the setting is saved.
func (a *App) startBandOpenFeed() {
if a.pskr != nil {
a.pskr.Stop()
a.pskr = nil
}
s := a.GetBandOpenSettings()
a.bandOpen.on.Store(s.Enabled)
if !s.Enabled {
// Put out whatever is currently lit. Leaving the badges up would keep
// announcing an opening from a watch that is now off, and they only fade
// on a timer fed by spots this path no longer looks at — so they would
// hang there until the app restarted.
a.clearBandOpenings()
return
}
// Every spot is measured from the operator's position. Without one there is
// nothing to measure, and a detector fed unmeasurable spots reports nothing
// while looking like it is working.
if !a.opSet {
applog.Printf("bandopen: no station grid set — the opening watch needs one to measure a path")
return
}
a.pskr = pskr.New(pskr.Config{
Bands: s.Bands,
OpLat: a.opLat, OpLon: a.opLon,
Geo: func(grid string) (int, int, bool) {
lat, lon, ok := gridToLatLon(grid)
if !ok {
return 0, 0, false
}
// The same arithmetic the cluster path uses, so one spot cannot be
// 2000 km away down one road and 2100 km down the other.
d := int(haversineKm(a.opLat, a.opLon, lat, lon) + 0.5)
b := int(initialBearingDeg(a.opLat, a.opLon, lat, lon) + 0.5)
return d, b, true
},
OnSpot: a.feedBandOpen,
Logf: applog.Printf,
})
if err := a.pskr.Start(); err != nil {
applog.Printf("bandopen: PSK Reporter feed did not start: %v", err)
}
}
// feedBandOpen hands one PSK Reporter decode to the detector.
//
// Called from the MQTT goroutine at up to thousands a minute when 6 m is open,
// so it does the least possible: the detector's own window and de-duplication
// by callsign are what turn that flood into one announcement.
func (a *App) feedBandOpen(s pskr.Spot) {
if !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.Call, Band: s.Band, DistKm: s.DistKm, Bearing: s.Bearing, At: s.At,
}, a.opLat)
if op != nil {
a.rememberOpening(*op)
}
if s.DistKm >= bandopen.DefaultConfig().MinKm {
a.markBandAlive(s.Band, s.At)
}
a.bandOpen.mu.Unlock()
if op != nil {
a.announceOpening(*op)
}
}
// GetPSKReporterStatus is what the settings panel polls.
func (a *App) GetPSKReporterStatus() pskr.Status {
if a.pskr == nil {
return pskr.Status{Bands: pskr.Bands}
}
return a.pskr.Status()
}
// settingOr reads one key, falling back when the store is not up yet or the
// value is blank. The settings store is a plain string key/value and every
// caller does this by hand; two of them here earn the helper.
func (a *App) settingOr(key, def string) string {
if a.settings == nil {
return def
}
v, _ := a.settings.Get(a.ctx, key)
if strings.TrimSpace(v) == "" {
return def
}
return v
}
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}