feat(cluster): feed the locator store from PSK Reporter, filtered at the broker

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.
This commit is contained in:
2026-08-12 17:16:47 +02:00
parent 82a49150d2
commit 7d33379fe1
15 changed files with 352 additions and 81 deletions
+44 -5
View File
@@ -81,7 +81,16 @@ type Config struct {
// OnSpot receives every accepted decode. Called from the MQTT goroutine, so
// it must not block: the broker's buffer is what pays for it if it does.
OnSpot func(Spot)
Logf func(string, ...any)
// OnGrid receives the transmitter of EVERY message, before any geographic
// filtering, for the callsign-to-locator store. Same goroutine as OnSpot and
// the same rule: do not block.
OnGrid func(call, grid string)
// RxGrids filters at the BROKER: only reports collected by a receiver in one
// of these squares are sent at all. Empty keeps the old behaviour, which was
// to receive the world and discard it here — measured at 83 messages a second
// for the four opening bands, of which about one in a hundred survived.
RxGrids []string
Logf func(string, ...any)
}
// Watcher owns the MQTT connection and its subscriptions.
@@ -115,6 +124,32 @@ func New(cfg Config) *Watcher {
return &Watcher{cfg: cfg}
}
// topics builds the subscription list.
//
// The v2 topic is
//
// pskr/filter/v2/<band>/<mode>/<tx call>/<rx call>/<tx grid>/<rx grid>/<tx dxcc>/<rx dxcc>
//
// so the receiver's square is a level the broker can filter on, and a band of
// "+" means every band. Filtering by RECEIVER square rather than by receiver
// DXCC is deliberate: measured on 20 m, one country ranged from 1.2 messages a
// second (OH) to 72.5 (K), because a DXCC can be a continent. By square the
// same measurement is 0.2 to 1.2 — the load follows distance, which is what the
// feed is actually about, and it is the same for every operator.
func (w *Watcher) topics() []string {
out := []string{}
for _, b := range w.cfg.Bands {
if len(w.cfg.RxGrids) == 0 {
out = append(out, "pskr/filter/v2/"+b+"/#")
continue
}
for _, g := range w.cfg.RxGrids {
out = append(out, "pskr/filter/v2/"+b+"/+/+/+/+/"+strings.ToUpper(g)+"/+/+")
}
}
return out
}
// Start connects and subscribes. Safe to call when already running.
func (w *Watcher) Start() error {
w.mu.Lock()
@@ -143,10 +178,7 @@ func (w *Watcher) Start() error {
opts.OnConnect = func(c mqtt.Client) {
w.cfg.Logf("pskr: connected to %s", w.cfg.Broker)
for _, b := range w.cfg.Bands {
// Every mode, every pair of stations, on this band. That firehose IS
// the point: the detector's job is to find the shape in it.
topic := "pskr/filter/v2/" + b + "/#"
for _, topic := range w.topics() {
if tok := c.Subscribe(topic, 0, w.handle); tok.Wait() && tok.Error() != nil {
w.cfg.Logf("pskr: subscribe %s failed: %v", topic, tok.Error())
continue
@@ -206,6 +238,13 @@ func (w *Watcher) handle(_ mqtt.Client, m mqtt.Message) {
return
}
// The locator store takes every transmitter, before any of the geography
// below. What it wants is "which square is this callsign in", and that is
// true whoever happened to hear the report.
if w.cfg.OnGrid != nil {
w.cfg.OnGrid(call, grid[:4])
}
// THE RECEIVER HAS TO BE NEAR THE OPERATOR. This is the whole difference
// between a useful feed and a world map.
//