mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 13:51:41 +00:00
22fe929da2
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>
242 lines
9.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|