Files
meshcore-analyzer/cmd/server/rx_coverage.go
T
efiten 22fe929da2 feat: opt-in mobile client-RX coverage (crowdsourced RF reach) + /api/nodes/resolve (#1728)
Implements #1727.

## What this adds

**Mobile client-RX coverage** — an opt-in, crowdsourced RF-coverage
feature. A roaming MeshCore **companion** radio (driven by the
open-source [corescope-rx](https://github.com/efiten/corescope-rx) PWA,
GPLv3) reports which nodes it heard directly, tagged with the phone's
GPS and the packet's SNR/RSSI. CoreScope ingests these into a new
`client_receptions` table and renders per-node **hex coverage** on the
Reach page, plus a standalone **Coverage dashboard** (`#/rx-coverage`)
with a top-mobile-observers leaderboard.

Also includes **`GET /api/nodes/resolve?prefix=<hex>`** — a read-only
node-name lookup by pubkey prefix (`{name, pubkey, ambiguous}`), used by
the companion app for friendly names.

## Opt-in — default OFF (zero impact on existing deployments)

The whole feature is gated behind one config flag, **disabled by
default**:

```jsonc
"clientRxCoverage": { "enabled": false }
```

When disabled (the default): the ingestor writes **no**
`client_receptions`; the three coverage endpoints return a clean
**404**; the UI hides the Coverage nav link, the `#/rx-coverage` route,
and the Reach-page toggle. `/api/nodes/resolve` is always available (not
coverage-specific).

## How it works

```
companion ──BLE 0x88 (snr+rssi+raw)──▶ corescope-rx PWA ──▶ MQTT meshcore/client/{pubkey}/packets
                                                                      │
                                          ingestor (gated) ──▶ client_receptions (GPS + SNR + heard-key)
                                                                      │
              server: pure-Go hex grid ──▶ GeoJSON ──▶ Reach hex overlay + Coverage dashboard
```

- **Direct-only capture:** records only what the companion heard itself
and directly — a 0-hop advert's pubkey, or `path[last]` (last forwarder)
for FLOOD routes; ≥2-byte path-hash required. Upstream hops discarded.
- **No new deps:** hexbins are a pure-Go pointy-top grid over Web
Mercator (`cmd/server/hexgrid.go`) computed at query time
(`CGO_ENABLED=0` / `modernc.org/sqlite` friendly); frontend uses the
existing Leaflet.
- **Trust:** companion pubkey = identity; an EMQX ACL binds each client
to publish only to its own `meshcore/client/{pubkey}/packets` topic.
Payload contract in `docs/client-rx-coverage.md`.

## How to enable / try it

1. In `config.json`, set `"clientRxCoverage": { "enabled": true }` and
restart server + ingestor.
2. Point an EMQX (or any broker) listener so a client can publish to
`meshcore/client/<pubkey>/packets`; the ingestor already subscribes
under `meshcore/#`.
3. Run the [corescope-rx](https://github.com/efiten/corescope-rx) PWA on
an Android phone paired (BLE) to a MeshCore companion — it captures
heard nodes + GPS and publishes.
4. View results: per-node Reach page → toggle **coverage**, or the
**Coverage** dashboard at `#/rx-coverage`.

## What's where

- **Ingestor:** `cmd/ingestor/client_reception.go` (ingest), `db.go`
(`client_receptions` + `client_observers` schema), `main.go` (gated
dispatch), `config.go` (flag).
- **Server:** `cmd/server/rx_coverage.go` + `rx_dashboard.go`
(endpoints, self-guard 404 when off), `hexgrid.go` (pure-Go grid),
`node_resolve.go` (resolve), `routes.go` / `types.go` / `config.go`
(wiring + flag + `/api/config/client` field).
- **Frontend:** `public/rx-coverage.js` (dashboard),
`node-reach-coverage.js` + `.css` (overlay), `node-reach.js` (Reach
toggle, flag-gated), `roles.js` (reads the flag, hides nav when off).
- **Docs:** `docs/client-rx-coverage.md`.

## Testing

- Go: `cd cmd/server && go test ./...` and `cd cmd/ingestor && go test
./...` — green, including new gate tests (`coverage_gate_test.go` in
both: off → no rows / 404, on → works) and the rx-coverage / resolve /
hexgrid suites.
- JS: `node test-coverage-gate.js`, `node test-node-reach-coverage.js`
(wired into CI). The Playwright `test-node-reach-coverage-e2e.js` is
wired into the e2e job and **skips when `clientRxCoverage` is
disabled**, so it's safe under the default-off config.

## Notes for reviewers

- The four new routes are registered in
`cmd/server/openapi_known_gaps.json` (the existing OpenAPI-completeness
ratchet), matching how other not-yet-spec'd routes are tracked. Happy to
write full OpenAPI spec entries instead if you prefer.
- Commits are split per layer (ingestor / server endpoints / resolve /
frontend / CI) for review.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Erwin Fiten <e.fiten@opteco.be>
2026-06-19 11:37:16 -07:00

371 lines
13 KiB
Go

package main
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"github.com/gorilla/mux"
)
// coverageRow is one raw reception read from client_receptions.
type coverageRow struct {
Lat, Lon float64
SNR *float64
RSSI *int
HeardKey string // directly-heard node key (2-3 byte prefix or full pubkey), lowercase
RxAt string // reception time (RFC3339); used to pick the latest SNR per node
}
// coverageFeatureCap bounds the number of hex cells returned in one response.
// A wide bbox at high zoom over the 30-day window could otherwise emit multi-MB
// GeoJSON; when more cells exist the densest are kept and Truncated is set (#12).
const coverageFeatureCap = 5000
// coverageCellNodeCap bounds the per-cell node breakdown shipped on the wire
// (the client only renders the top ~10). NodesTruncated flags that more were
// heard than returned (#11).
const coverageCellNodeCap = 25
// GeoJSON output (named structs, no map[string]interface{} — AGENTS.md).
// Truncated is a non-standard foreign member (ignored by GeoJSON consumers like
// Leaflet) that signals the cell list was capped at coverageFeatureCap.
type CoverageFeatureCollection struct {
Type string `json:"type"` // "FeatureCollection"
Features []CoverageFeature `json:"features"`
Truncated bool `json:"truncated,omitempty"`
// Per-node summary (set only by the per-node endpoint): total mobile-client
// receptions of this node and how many distinct companions heard it. Foreign
// members, omitempty so the global endpoint's payload is unchanged (#3).
MobileReceptions int `json:"mobile_receptions,omitempty"`
MobileClients int `json:"mobile_clients,omitempty"`
}
type CoverageFeature struct {
Type string `json:"type"` // "Feature"
Geometry CoveragePolygon `json:"geometry"`
Properties CoverageProperties `json:"properties"`
}
type CoveragePolygon struct {
Type string `json:"type"` // "Polygon"
Coordinates [][][2]float64 `json:"coordinates"` // one ring: [ [ [lon,lat], ... ] ]
}
type CoverageProperties struct {
Cell string `json:"cell"`
Count int `json:"count"`
BestSNR *float64 `json:"best_snr"`
HasSig bool `json:"has_sig"` // false → render grey (no signal metric)
Nodes []CoverageNode `json:"nodes"` // per-node breakdown, strongest latest-SNR first
NodesTruncated bool `json:"nodes_truncated,omitempty"` // true → more nodes heard than returned (#11)
}
// CoverageNode is one directly-heard node within a cell, with its latest SNR.
type CoverageNode struct {
Prefix string `json:"prefix"` // heard_key (resolved to Name when unique)
Name string `json:"name,omitempty"` // node name, empty if unknown/ambiguous prefix
SNR *float64 `json:"snr"` // latest SNR (by rx_at); nil → heard without signal
Count int `json:"count"`
}
type covAgg struct {
count int
bestSNR *float64
hasSig bool
nodes map[string]*covNodeAgg
}
// covNodeAgg tracks, per directly-heard node within a cell, its reception count and
// the SNR of its most recent reception (by rx_at). name/prefix are the resolved node
// name (when known) and a display prefix fallback. nameKeyLen records the heard_key
// length that set the current name, so the chosen identity is the most specific one
// regardless of row order (#20).
type covNodeAgg struct {
count int
latestAt string
latestSNR *float64
name string
nameKeyLen int
prefix string
}
// nodeResolver maps a heard_key (2-3 byte prefix or full pubkey) to a canonical
// identity key and a display name. A unique match returns (pubkey, name) so the same
// node heard under different prefix lengths collapses into one bucket; unknown or
// ambiguous keys return (heardKey, "") and stay distinct. nil disables resolution.
type nodeResolver func(heardKey string) (key, name string)
// aggregateCoverage bins raw rows into display-resolution hex cells, keeping the
// best (max) SNR per cell, and emits GeoJSON polygons. resolve (may be nil) collapses
// per-node receptions by resolved node identity.
func aggregateCoverage(rows []coverageRow, res int, resolve nodeResolver) CoverageFeatureCollection {
byCell := map[string]*covAgg{}
for _, row := range rows {
cell := hexCellAt(row.Lat, row.Lon, res)
a := byCell[cell]
if a == nil {
a = &covAgg{}
byCell[cell] = a
}
a.count++
if row.SNR != nil {
a.hasSig = true
if a.bestSNR == nil || *row.SNR > *a.bestSNR {
v := *row.SNR
a.bestSNR = &v
}
}
if row.HeardKey != "" {
if a.nodes == nil {
a.nodes = map[string]*covNodeAgg{}
}
key, name := row.HeardKey, ""
if resolve != nil {
if k, n := resolve(row.HeardKey); k != "" {
key, name = k, n
}
}
na := a.nodes[key]
if na == nil {
na = &covNodeAgg{prefix: row.HeardKey}
a.nodes[key] = na
}
// Lock the display identity to the MOST SPECIFIC (longest) heard_key
// that resolved to a non-empty name, tie-broken lexicographically, so
// the name no longer flaps with row/map order (#20). A full-pubkey
// reception thus outranks a short-prefix one for the same node.
if name != "" && (na.name == "" || len(row.HeardKey) > na.nameKeyLen ||
(len(row.HeardKey) == na.nameKeyLen && name < na.name)) {
na.name = name
na.nameKeyLen = len(row.HeardKey)
}
// Display-prefix fallback (shown when name is empty): same precedence so
// it is also order-independent.
if len(row.HeardKey) > len(na.prefix) ||
(len(row.HeardKey) == len(na.prefix) && row.HeardKey < na.prefix) {
na.prefix = row.HeardKey
}
na.count++
// rx_at is RFC3339, so lexical >= is chronological; keep the latest
// SNR. The first row always wins (latestAt starts "", and any value
// >= ""), so no separate count==1 guard is needed.
if row.RxAt >= na.latestAt {
na.latestAt = row.RxAt
na.latestSNR = row.SNR
}
}
}
fc := CoverageFeatureCollection{Type: "FeatureCollection", Features: []CoverageFeature{}}
for cell, a := range byCell {
ring := hexBoundary(cell)
if ring == nil {
continue
}
nodes, nodesTrunc := sortedCoverageNodes(a.nodes)
fc.Features = append(fc.Features, CoverageFeature{
Type: "Feature",
Geometry: CoveragePolygon{Type: "Polygon", Coordinates: [][][2]float64{ring}},
Properties: CoverageProperties{
Cell: cell, Count: a.count, BestSNR: a.bestSNR, HasSig: a.hasSig,
Nodes: nodes, NodesTruncated: nodesTrunc,
},
})
}
// Bound the response: when more cells exist than coverageFeatureCap, keep the
// densest (highest count) and flag the truncation, so a wide/zoomed-out query
// can't emit unbounded multi-MB GeoJSON (#12).
if len(fc.Features) > coverageFeatureCap {
sort.Slice(fc.Features, func(i, j int) bool {
ci, cj := fc.Features[i].Properties.Count, fc.Features[j].Properties.Count
if ci != cj {
return ci > cj // densest first
}
return fc.Features[i].Properties.Cell < fc.Features[j].Properties.Cell // deterministic tie-break
})
fc.Features = fc.Features[:coverageFeatureCap]
fc.Truncated = true
}
// Map iteration is randomized, so sort features by cell for a deterministic
// payload — stable ETag/caching and a non-flaky "first feature" in e2e (#8).
sort.Slice(fc.Features, func(i, j int) bool {
return fc.Features[i].Properties.Cell < fc.Features[j].Properties.Cell
})
return fc
}
// sortedCoverageNodes flattens the per-node aggregates into a slice sorted by latest
// SNR descending (nodes heard without a signal sort last), tie-broken by count then
// prefix for a stable order. The slice is capped at coverageCellNodeCap; truncated
// reports whether more nodes were heard in the cell than returned (#11).
func sortedCoverageNodes(m map[string]*covNodeAgg) (nodes []CoverageNode, truncated bool) {
out := make([]CoverageNode, 0, len(m))
for _, na := range m {
out = append(out, CoverageNode{Prefix: na.prefix, Name: na.name, SNR: na.latestSNR, Count: na.count})
}
sort.Slice(out, func(i, j int) bool {
si, sj := out[i].SNR, out[j].SNR
if (si == nil) != (sj == nil) {
return si != nil // signal before no-signal
}
if si != nil && *si != *sj {
return *si > *sj
}
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Prefix < out[j].Prefix
})
if len(out) > coverageCellNodeCap {
return out[:coverageCellNodeCap], true
}
return out, false
}
type bbox struct{ MinLat, MinLon, MaxLat, MaxLon float64 }
// coverageHeardKeyCandidates returns the exact heard_key values that identify a
// node: its full pubkey (stored with heard_keylen 32) and the 2-byte (4 hex) and
// 3-byte (6 hex) prefixes a relay logs. Matching heard_key IN (these) is
// equivalent to the old "heard_keylen=32 AND heard_key=? OR heard_keylen IN (2,3)
// AND substr(?,1,keylen*2)=heard_key", but sargable — so the (heard_key, …)
// composite index seeks the few matching rows instead of scanning the bbox (#5).
func coverageHeardKeyCandidates(pubkey string) []string {
pk := strings.ToLower(pubkey)
seen := map[string]bool{}
out := make([]string, 0, 3)
for _, c := range []string{pk, prefixOrEmpty(pk, 6), prefixOrEmpty(pk, 4)} {
if c != "" && !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out
}
func prefixOrEmpty(s string, n int) string {
if len(s) >= n {
return s[:n]
}
return ""
}
// sqlPlaceholders returns "?,?,…" with n placeholders (n >= 1).
func sqlPlaceholders(n int) string {
if n <= 1 {
return "?"
}
return strings.Repeat("?,", n-1) + "?"
}
// queryCoverageRows returns raw coverage rows where the directly-heard node
// matches the target pubkey by its 2-3 byte prefix (or full pubkey), within the
// bbox. Read-only (server RO connection).
func (s *Server) queryCoverageRows(pubkey string, b bbox) ([]coverageRow, error) {
cands := coverageHeardKeyCandidates(pubkey)
args := make([]interface{}, 0, len(cands)+4)
for _, c := range cands {
args = append(args, c)
}
args = append(args, b.MinLat, b.MaxLat, b.MinLon, b.MaxLon)
rows, err := s.db.conn.Query(`
SELECT lat, lon, snr, rssi, heard_key, rx_at
FROM client_receptions
WHERE heard_key IN (`+sqlPlaceholders(len(cands))+`)
AND lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCoverageRows(rows)
}
// mobileRxStats returns the total mobile-client receptions of a node (by its
// 2-3 byte prefix or full pubkey) and the number of distinct contributing clients.
func (s *Server) mobileRxStats(pubkey string) (count, clients int) {
if s.db == nil || s.db.conn == nil {
return 0, 0
}
cands := coverageHeardKeyCandidates(pubkey)
args := make([]interface{}, len(cands))
for i, c := range cands {
args[i] = c
}
s.db.conn.QueryRow(`
SELECT COUNT(*), COUNT(DISTINCT rx_pubkey) FROM client_receptions
WHERE heard_key IN (`+sqlPlaceholders(len(cands))+`)`, args...).Scan(&count, &clients)
return count, clients
}
// zoomToHexRes maps a Leaflet zoom level to the display resolution used for hex
// binning. Resolution == zoom (clamped to a sane range) so hex size tracks the map
// scale 1:1 and renders at a constant ~hexTargetPx (see hexSizeForRes). The clamp also
// guards the missing-param case (z parses to 0).
func zoomToHexRes(z int) int {
switch {
case z < 3:
return 3
case z > 18:
return 18
default:
return z
}
}
func parseBBox(s string) (bbox, bool) {
p := strings.Split(s, ",")
if len(p) != 4 {
return bbox{}, false
}
v := make([]float64, 4)
for i := range p {
f, err := strconv.ParseFloat(strings.TrimSpace(p[i]), 64)
if err != nil {
return bbox{}, false
}
v[i] = f
}
return bbox{MinLat: v[0], MinLon: v[1], MaxLat: v[2], MaxLon: v[3]}, true
}
// handleNodeRxCoverage serves per-node mobile RX coverage as a GeoJSON hex grid.
func (s *Server) handleNodeRxCoverage(w http.ResponseWriter, r *http.Request) {
if !s.requireClientRxCoverage(w, r) {
return
}
pubkey := strings.ToLower(mux.Vars(r)["pubkey"])
// Mirror handleNodeReach's gate at this same {pubkey}: reject malformed keys,
// and 404 blacklisted / hidden-prefix nodes. Hiding only the node *name* (via
// heardKeyResolver) still leaked the GPS hex bins and mobile_receptions /
// mobile_clients counts for a node the rest of the API hides (#1727 r2).
if !isHexPubkey(pubkey) {
http.Error(w, "invalid pubkey: expected 64 hex chars", http.StatusBadRequest)
return
}
if (s.cfg != nil && s.cfg.IsBlacklisted(pubkey)) || s.isPubkeyHidden(pubkey) {
http.NotFound(w, r)
return
}
b, ok := parseBBox(r.URL.Query().Get("bbox"))
if !ok {
http.Error(w, "bbox required as minLat,minLon,maxLat,maxLon", http.StatusBadRequest)
return
}
if s.db == nil || s.db.conn == nil {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
return
}
z, _ := strconv.Atoi(r.URL.Query().Get("z"))
rows, err := s.queryCoverageRows(pubkey, b)
if err != nil {
http.Error(w, "query failed", http.StatusInternalServerError)
return
}
fc := aggregateCoverage(rows, zoomToHexRes(z), s.heardKeyResolverFor(rows))
// Attach the node-wide reception/contributor totals (#3): the bbox limits the
// hex features to the current view, but these summarise all of this node's
// mobile coverage so the UI can show "heard by N clients" regardless of pan.
fc.MobileReceptions, fc.MobileClients = s.mobileRxStats(pubkey)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(fc)
}