The store shipped without its main source. Locators came only from this station's own WSJT-X decodes, which is what the whole MQTT discussion was about. PSK Reporter now feeds it through a new OnGrid callback, fired before any geographic filtering: what the store wants is "which square is this callsign in", and that is true whoever happened to hear the report. The subscription filters on the RECEIVER's square, a level the v2 topic exposes. Measured on the live feed: the four opening bands unfiltered are 83 messages a second, of which roughly one in a hundred survived the NearKm test that already existed here — the rest was received, TLS-decrypted, JSON-parsed and discarded. One ring of squares is 0.2 to 1.2 a second. By square rather than by DXCC, which was the obvious alternative: one country measured 1.2 messages a second (OH) against 72.5 (K) on a single band, because a DXCC can be a continent. By square the same measurement is 0.2 to 1.2, so the load follows distance — what the feed is actually about — and is the same for every operator. Grid chasing subscribes with the "+" band wildcard, so one subscription per square covers every band instead of one per band per square. The store gains a source column (decode | mqtt), migrated in place on an existing file.
260 lines
8.2 KiB
Go
260 lines
8.2 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/geo"
|
|
"hamlog/internal/gridcache"
|
|
"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()
|
|
}
|
|
chaseGrids := a.gridStore != nil
|
|
if !s.Enabled && !chaseGrids {
|
|
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("pskr: no station grid set — the feed needs one to measure a path")
|
|
return
|
|
}
|
|
|
|
// Grid chasing wants every band; the opening watch wants its four. "+" is the
|
|
// MQTT single-level wildcard, so one subscription per square covers the lot.
|
|
bands := s.Bands
|
|
if chaseGrids {
|
|
bands = []string{"+"}
|
|
}
|
|
// Filter at the BROKER on the receiver's square rather than receiving the
|
|
// world and discarding it here. Measured on the live feed: the four opening
|
|
// bands unfiltered are 83 messages a second, of which roughly one in a
|
|
// hundred survived the NearKm test below. One ring of squares — about the
|
|
// same 300 km — is 0.2 to 1.2 a second, and the same for every operator,
|
|
// where filtering by DXCC ranged from 1.2 (OH) to 72.5 (K).
|
|
rxGrids := geo.NeighbourGrids(a.opLat, a.opLon, 1)
|
|
|
|
var onGrid func(call, grid string)
|
|
if chaseGrids {
|
|
onGrid = func(call, grid string) { a.rememberDecodeGrid(call, grid, gridcache.SourceMQTT) }
|
|
}
|
|
var onSpot func(pskr.Spot)
|
|
if s.Enabled {
|
|
onSpot = a.feedBandOpen
|
|
}
|
|
|
|
a.pskr = pskr.New(pskr.Config{
|
|
Bands: bands,
|
|
RxGrids: rxGrids,
|
|
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: onSpot,
|
|
OnGrid: onGrid,
|
|
Logf: applog.Printf,
|
|
})
|
|
if err := a.pskr.Start(); err != nil {
|
|
applog.Printf("pskr: feed did not start: %v", err)
|
|
return
|
|
}
|
|
applog.Printf("pskr: feed up — bands %v, %d receiver squares (openings=%v, grids=%v)",
|
|
bands, len(rxGrids), s.Enabled, chaseGrids)
|
|
}
|
|
|
|
// 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
|
|
}
|