A saved band selection outlives the code that made it. 12 m was removed 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. The stored list is filtered against the offered one now. The badge appeared on the next poll rather than on the event, up to twenty seconds after the announcement, so the two read as unrelated things that happened to mention the same band. It appears immediately. And the toast is gone. It said the same thing as the badge and vanished after five seconds — an operator who was tuning when it passed had no way back to it, which is precisely what the badge was built to fix. Keeping both announced an opening twice and still lost it once.
220 lines
6.7 KiB
Go
220 lines
6.7 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()
|
|
if !s.Enabled {
|
|
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
|
|
}
|