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
+31 -9
View File
@@ -1392,13 +1392,12 @@ func (a *App) startup(ctx context.Context) {
go a.sendTelemetryHeartbeat()
go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL
go a.chatLoop() // multi-op: poll the shared chat + heartbeat presence
// PSK Reporter, when the opening watch is on. After the operator's grid is
// known: without it there is no distance to measure and the feed stays down.
a.startBandOpenFeed()
// Locator store. After settings are scoped, so the option is read from the
// right profile, and before the cluster starts serving statuses so the first
// spots already carry their locators.
// Locator store BEFORE the feed: the feed asks whether it exists to decide
// which bands to subscribe to.
a.startGridCache()
// PSK Reporter. After the operator's grid is known: without it there is no
// distance to measure and no receiver squares to filter on, so it stays down.
a.startBandOpenFeed()
// One-time tidy-up of a field nothing used to record. Background, once.
a.backfillDistancesOnce()
@@ -11966,7 +11965,7 @@ func (a *App) consumeUDPEvents() {
// Remember the grid before anything else: a CQ is the one message that
// carries it, and the station may never send another.
if ev.DecodeGrid != "" {
a.rememberDecodeGrid(ev.DecodeCall, ev.DecodeGrid)
a.rememberDecodeGrid(ev.DecodeCall, ev.DecodeGrid, gridcache.SourceDecode)
}
// A WSJT-X decode (heard station). Render it on the FlexRadio
// panadapter when the option is on; green + SNR comment, auto-expiring
@@ -17046,10 +17045,33 @@ const decodeGridsCap = 100000
// GetChaseNewGrids reports whether learnt locators are kept across restarts.
func (a *App) GetChaseNewGrids() bool { return a.settingOr(keyChaseNewGrids, "") == "1" }
// GridCacheStatus is the live count under the option. A store that is on but
// has learnt nothing looks exactly like a broken one until a number moves.
type GridCacheStatus struct {
Enabled bool `json:"enabled"`
Known int `json:"known"` // locators in memory, stored + learnt this session
Pending int `json:"pending"` // waiting for the next batch write
}
// GetGridCacheStatus reports what the locator store holds.
func (a *App) GetGridCacheStatus() GridCacheStatus {
out := GridCacheStatus{Enabled: a.gridStore != nil}
a.decodeGridsMu.RLock()
out.Known = len(a.decodeGrids) + len(a.decodeGridsOld)
a.decodeGridsMu.RUnlock()
if a.gridStore != nil {
out.Pending = a.gridStore.Pending()
}
return out
}
// SetChaseNewGrids turns grid chasing on or off and applies it immediately.
func (a *App) SetChaseNewGrids(on bool) error {
a.setSetting(keyChaseNewGrids, boolStr(on))
a.startGridCache()
// Resubscribe: with grid chasing the feed covers every band, without it only
// the opening bands — and with neither consumer it comes down entirely.
a.startBandOpenFeed()
return nil
}
@@ -17108,7 +17130,7 @@ func (a *App) startGridCache() {
// previous one and a fresh map takes over. Nothing is scanned, nothing is
// timestamped, and the write stays a single map assignment — which matters
// because this runs once per decode.
func (a *App) rememberDecodeGrid(call, grid string) {
func (a *App) rememberDecodeGrid(call, grid, source string) {
call = strings.ToUpper(strings.TrimSpace(call))
grid = strings.TrimSpace(grid)
if call == "" || grid == "" {
@@ -17140,7 +17162,7 @@ func (a *App) rememberDecodeGrid(call, grid string) {
a.decodeGridsMu.Unlock()
if changed && store != nil {
store.Put(call, grid)
store.Put(call, grid, source)
}
}