PSK Reporter tells you what is actually being decoded in your region, which is a larger set than what somebody chose to spot: nobody spots the FT8 caller running ten watts from a rare square. Almost all of it existed. The MQTT payload already carries frequency, mode, transmitter and both grids; the watcher already drops any report collected further than NearKm from the operator, which is exactly the question worth asking — the station is being heard HERE, not in Japan; and with grid chasing on the subscription is already every band, filtered at the broker by receiver square, measured at 0.2 to 1.2 messages a second. This reads messages that were arriving and being discarded. "New" is not decided here. Every spot goes through ClusterSpotStatuses, the same function the DX cluster grid uses and the same cached index, so the two panels cannot drift apart the way the county columns did. Cost per message is map lookups behind an option cached in an atomic, because the MQTT goroutine must never wait on the settings store. The option is its own, not nested under grid chasing: chasing squares and chasing entities are different wants, and the feed now has three consumers, any one of which brings it up and none of which cuts the others loose when it goes down. The panel says "digital modes only" in its footer. An empty list has to mean "nothing new on FT8/FT4/JS8 near you", not "the band is dead" — it will never show a new entity on CW.
270 lines
8.7 KiB
Go
270 lines
8.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/geo"
|
|
"hamlog/internal/gridcache"
|
|
"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)
|
|
a.setBandOpenBands(s.Bands)
|
|
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
|
|
chaseNew := a.chaseNewEnabled()
|
|
// Three consumers, one feed. Any one of them is reason enough to bring it up,
|
|
// and turning one off must not cut the others loose.
|
|
if !s.Enabled && !chaseGrids && !chaseNew {
|
|
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 and new-chasing want 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 || chaseNew {
|
|
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)
|
|
switch {
|
|
case s.Enabled && chaseNew:
|
|
onSpot = func(sp pskr.Spot) { a.feedBandOpen(sp); a.feedChaseNew(sp) }
|
|
case s.Enabled:
|
|
onSpot = a.feedBandOpen
|
|
case chaseNew:
|
|
onSpot = a.feedChaseNew
|
|
}
|
|
|
|
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, chase-new=%v)",
|
|
bands, len(rxGrids), s.Enabled, chaseGrids, chaseNew)
|
|
}
|
|
|
|
// 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 !a.bandOpenWanted(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
|
|
}
|