Files
meshcore-analyzer/cmd/server/rx_coverage_test.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

242 lines
9.9 KiB
Go

package main
import (
"encoding/json"
"fmt"
"math"
"testing"
)
// TestAggregateCoverageCapsNodesPerCell verifies #11: a cell that heard more than
// coverageCellNodeCap distinct nodes ships at most that many, with NodesTruncated set.
func TestAggregateCoverageCapsNodesPerCell(t *testing.T) {
rows := make([]coverageRow, 0, coverageCellNodeCap+5)
for i := 0; i < coverageCellNodeCap+5; i++ {
rows = append(rows, coverageRow{
Lat: 51.05, Lon: 3.72, SNR: covF(float64(-i)),
HeardKey: fmt.Sprintf("aa%06x", i), RxAt: "2026-06-01T10:00:00Z",
})
}
fc := aggregateCoverage(rows, 9, nil)
if len(fc.Features) != 1 {
t.Fatalf("expected 1 cell, got %d", len(fc.Features))
}
p := fc.Features[0].Properties
if len(p.Nodes) != coverageCellNodeCap || !p.NodesTruncated {
t.Fatalf("want %d nodes + truncated, got %d nodes truncated=%v", coverageCellNodeCap, len(p.Nodes), p.NodesTruncated)
}
}
// TestAggregateCoverageCapsFeatures verifies #12: a query spanning more than
// coverageFeatureCap cells is bounded to that many features with Truncated set,
// and a smaller query is not truncated.
func TestAggregateCoverageCapsFeatures(t *testing.T) {
// 0.1° spacing >> a res-9 cell (~4 km), so each point lands in its own cell.
rows := make([]coverageRow, 0, coverageFeatureCap+200)
side := 75 // 75*75 = 5625 > 5000
for i := 0; i < side*side; i++ {
lat := 10.0 + float64(i/side)*0.1
lon := 10.0 + float64(i%side)*0.1
rows = append(rows, coverageRow{Lat: lat, Lon: lon, SNR: covF(-5)})
}
fc := aggregateCoverage(rows, 9, nil)
if len(fc.Features) != coverageFeatureCap || !fc.Truncated {
t.Fatalf("want %d features + truncated, got %d truncated=%v", coverageFeatureCap, len(fc.Features), fc.Truncated)
}
// Still sorted by cell after truncation.
for i := 1; i < len(fc.Features); i++ {
if fc.Features[i-1].Properties.Cell > fc.Features[i].Properties.Cell {
t.Fatalf("truncated features not sorted by cell at %d", i)
}
}
// A small query is not truncated.
small := aggregateCoverage(rows[:10], 9, nil)
if small.Truncated {
t.Fatalf("small query should not be truncated")
}
}
func covF(f float64) *float64 { return &f }
func TestAggregateCoverageBucketsBestSNR(t *testing.T) {
rows := []coverageRow{
{Lat: 51.05000, Lon: 3.72000, SNR: covF(-12)},
{Lat: 51.05001, Lon: 3.72001, SNR: covF(-6)}, // same cell, stronger
}
fc := aggregateCoverage(rows, 9, nil)
if len(fc.Features) != 1 {
t.Fatalf("expected 1 cell, got %d", len(fc.Features))
}
if p := fc.Features[0].Properties; p.BestSNR == nil || *p.BestSNR != -6 || p.Count != 2 || !p.HasSig {
t.Fatalf("bad props: %+v", fc.Features[0].Properties)
}
if g := fc.Features[0].Geometry; g.Type != "Polygon" || len(g.Coordinates) != 1 {
t.Fatalf("bad geometry: %+v", g)
}
if _, err := json.Marshal(fc); err != nil {
t.Fatalf("marshal: %v", err)
}
}
func TestAggregateCoverageGreyWhenNoSignal(t *testing.T) {
fc := aggregateCoverage([]coverageRow{{Lat: 51.05, Lon: 3.72}}, 9, nil)
if len(fc.Features) != 1 || fc.Features[0].Properties.HasSig {
t.Fatalf("expected one grey (no-sig) cell, got %+v", fc.Features)
}
}
// TestAggregateCoverageNodeBreakdown covers the per-cell node list: each heard node
// keeps its latest SNR (by rx_at) and reception count, sorted strongest-first with
// heard-without-signal nodes last.
func TestAggregateCoverageNodeBreakdown(t *testing.T) {
rows := []coverageRow{
// node A: two receptions; the later one (t2) has the weaker SNR -10.
{Lat: 51.05, Lon: 3.72, SNR: covF(-4), HeardKey: "aabb", RxAt: "2026-06-01T10:00:00Z"},
{Lat: 51.05001, Lon: 3.72001, SNR: covF(-10), HeardKey: "aabb", RxAt: "2026-06-02T10:00:00Z"},
// node B: single reception, strongest latest SNR.
{Lat: 51.05, Lon: 3.72, SNR: covF(-6), HeardKey: "ccdd", RxAt: "2026-06-01T10:00:00Z"},
// node C: heard without a signal metric.
{Lat: 51.05, Lon: 3.72, HeardKey: "eeff", RxAt: "2026-06-01T10:00:00Z"},
}
fc := aggregateCoverage(rows, 9, nil)
if len(fc.Features) != 1 {
t.Fatalf("expected 1 cell, got %d", len(fc.Features))
}
nodes := fc.Features[0].Properties.Nodes
if len(nodes) != 3 {
t.Fatalf("expected 3 nodes, got %d (%+v)", len(nodes), nodes)
}
if nodes[0].Prefix != "ccdd" || nodes[0].SNR == nil || *nodes[0].SNR != -6 {
t.Errorf("node[0] want ccdd@-6 (strongest), got %+v", nodes[0])
}
if nodes[1].Prefix != "aabb" || nodes[1].SNR == nil || *nodes[1].SNR != -10 || nodes[1].Count != 2 {
t.Errorf("node[1] want aabb latest -10 count 2, got %+v", nodes[1])
}
if nodes[2].Prefix != "eeff" || nodes[2].SNR != nil {
t.Errorf("node[2] want eeff no-signal (last), got %+v", nodes[2])
}
}
// TestResolveHeardKey covers heard_key → (pubkey, name) resolution: a unique match
// returns the canonical pubkey + name; an ambiguous prefix (>1 node) and an
// unknown/empty key return the key itself with an empty name.
func TestResolveHeardKey(t *testing.T) {
db := seedCoverageDB(t)
mustExecDB(t, db, `INSERT INTO nodes (public_key,name,role) VALUES ('aabbccdd11223344','Alice','repeater')`)
mustExecDB(t, db, `INSERT INTO nodes (public_key,name,role) VALUES ('aabbcc99887766aa','Bob','repeater')`)
srv := &Server{db: db}
if k, n := srv.resolveHeardKey("aabbccdd"); k != "aabbccdd11223344" || n != "Alice" {
t.Errorf("unique prefix → (pubkey,Alice), got (%q,%q)", k, n)
}
if k, n := srv.resolveHeardKey("aabbcc"); k != "aabbcc" || n != "" {
t.Errorf("ambiguous prefix → (key,\"\"), got (%q,%q)", k, n)
}
if k, n := srv.resolveHeardKey("ffff"); k != "ffff" || n != "" {
t.Errorf("unknown prefix → (key,\"\"), got (%q,%q)", k, n)
}
if k, n := srv.resolveHeardKey(""); k != "" || n != "" {
t.Errorf("empty prefix → (\"\",\"\"), got (%q,%q)", k, n)
}
}
// TestAggregateCoverageMergesResolvedNodes verifies that the same node heard under
// two different heard_keys (e.g. a 3-byte prefix and the full pubkey) collapses into a
// single entry — summed count, latest SNR — when the resolver maps both to one node.
func TestAggregateCoverageMergesResolvedNodes(t *testing.T) {
rows := []coverageRow{
{Lat: 51.05, Lon: 3.72, SNR: covF(-4), HeardKey: "aabbcc", RxAt: "2026-06-01T10:00:00Z"},
{Lat: 51.05, Lon: 3.72, SNR: covF(-9), HeardKey: "aabbccdd11223344", RxAt: "2026-06-03T10:00:00Z"},
{Lat: 51.05, Lon: 3.72, SNR: covF(-7), HeardKey: "aabbcc", RxAt: "2026-06-02T10:00:00Z"},
}
resolve := func(hk string) (string, string) { return "aabbccdd11223344", "Alice" }
fc := aggregateCoverage(rows, 9, resolve)
if len(fc.Features) != 1 {
t.Fatalf("expected 1 cell, got %d", len(fc.Features))
}
nodes := fc.Features[0].Properties.Nodes
if len(nodes) != 1 {
t.Fatalf("expected 1 merged node, got %d (%+v)", len(nodes), nodes)
}
n := nodes[0]
if n.Name != "Alice" || n.Count != 3 || n.SNR == nil || *n.SNR != -9 {
t.Errorf("merged node want Alice count 3 latest -9, got %+v (snr=%v)", n, n.SNR)
}
}
// TestAggregateCoverageDeterministicFeatureOrder verifies #8: features come out
// sorted by cell regardless of Go's randomized map iteration, so the GeoJSON is
// stable (cacheable / non-flaky e2e).
func TestAggregateCoverageDeterministicFeatureOrder(t *testing.T) {
rows := []coverageRow{
{Lat: 51.0, Lon: 3.0, SNR: covF(-5)},
{Lat: 48.0, Lon: 2.0, SNR: covF(-5)},
{Lat: 52.0, Lon: 4.0, SNR: covF(-5)},
{Lat: 40.0, Lon: -3.0, SNR: covF(-5)},
}
fc := aggregateCoverage(rows, 9, nil)
if len(fc.Features) < 2 {
t.Fatalf("expected multiple cells, got %d", len(fc.Features))
}
for i := 1; i < len(fc.Features); i++ {
if fc.Features[i-1].Properties.Cell > fc.Features[i].Properties.Cell {
t.Fatalf("features not sorted by cell at %d: %q > %q", i,
fc.Features[i-1].Properties.Cell, fc.Features[i].Properties.Cell)
}
}
}
// TestAggregateCoverageNamePrecedenceOrderIndependent verifies #20: when two
// heard_keys resolve to the same node but the resolver returns different display
// names, the most specific (longest) heard_key wins regardless of row order, so
// the name no longer depends on map/row iteration.
func TestAggregateCoverageNamePrecedenceOrderIndependent(t *testing.T) {
resolve := func(hk string) (string, string) {
if hk == "aabbccdd11223344" {
return "aabbccdd11223344", "Alice"
}
return "aabbccdd11223344", "AliceShortPrefix"
}
full := coverageRow{Lat: 51.05, Lon: 3.72, SNR: covF(-5), HeardKey: "aabbccdd11223344", RxAt: "2026-06-01T10:00:00Z"}
prefix := coverageRow{Lat: 51.05, Lon: 3.72, SNR: covF(-6), HeardKey: "aabbcc", RxAt: "2026-06-02T10:00:00Z"}
for _, order := range [][]coverageRow{{full, prefix}, {prefix, full}} {
fc := aggregateCoverage(order, 9, resolve)
nodes := fc.Features[0].Properties.Nodes
if len(nodes) != 1 {
t.Fatalf("expected 1 merged node, got %d (%+v)", len(nodes), nodes)
}
if nodes[0].Name != "Alice" {
t.Fatalf("name precedence flapped with row order: got %q, want Alice", nodes[0].Name)
}
}
}
func TestZoomToHexRes(t *testing.T) {
// Resolution tracks zoom 1:1 within [3,18], clamped at the edges (z=0 is the
// missing-param case).
cases := map[int]int{0: 3, 3: 3, 8: 8, 16: 16, 18: 18, 25: 18}
for z, want := range cases {
if got := zoomToHexRes(z); got != want {
t.Fatalf("zoomToHexRes(%d)=%d, want %d", z, got, want)
}
}
}
// TestHexSizeRendersConstantPx verifies the core fix: a hex sized for resolution
// res renders at a constant ~hexTargetPx on screen at the corresponding zoom level,
// instead of the old fixed-meter buckets that were ~2px when zoomed out.
func TestHexSizeRendersConstantPx(t *testing.T) {
for res := 4; res <= 16; res++ {
// On-screen point-to-point height = 2*circumradius / mercUnitsPerPixel(zoom),
// where mercUnitsPerPixel = mercUPPZ0 / 2^zoom and zoom == res.
px := 2 * hexSizeForRes(res) * math.Pow(2, float64(res)) / mercUPPZ0
if math.Abs(px-hexTargetPx) > 0.001 {
t.Fatalf("res %d renders %.2fpx, want %.2fpx", res, px, hexTargetPx)
}
// Size must halve each zoom step (finer grid as you zoom in).
if ratio := hexSizeForRes(res) / hexSizeForRes(res+1); math.Abs(ratio-2) > 1e-9 {
t.Fatalf("res %d→%d size ratio %.4f, want 2", res, res+1, ratio)
}
}
}