mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 12:51:44 +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>
117 lines
5.2 KiB
Go
117 lines
5.2 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
func seedCoverageDB(t *testing.T) *DB {
|
|
db := setupTestDBv2(t)
|
|
mustExecDB(t, db, `CREATE TABLE client_receptions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, rx_pubkey TEXT, heard_key TEXT, heard_keylen INTEGER,
|
|
rssi INTEGER, snr REAL, lat REAL, lon REAL, pos_acc_m REAL, rx_at TEXT, ingested_at TEXT, src TEXT)`)
|
|
mustExecDB(t, db, `CREATE TABLE client_observers (pubkey TEXT PRIMARY KEY, name TEXT, last_seen TEXT)`)
|
|
return db
|
|
}
|
|
|
|
func TestQueryCoverageRowsByPrefixAndBBox(t *testing.T) {
|
|
db := seedCoverageDB(t)
|
|
mustExecDB(t, db, `INSERT INTO client_receptions (rx_pubkey,heard_key,heard_keylen,snr,lat,lon,rx_at,ingested_at,src)
|
|
VALUES ('comp','aabbcc',3,-6,51.05,3.72,'t','t','rxlog')`)
|
|
srv := &Server{db: db, cfg: &Config{ClientRxCoverage: &ClientRxCoverageConfig{Enabled: true}}}
|
|
|
|
rows, err := srv.queryCoverageRows("aabbccddeeff00112233", bbox{MinLat: 50, MinLon: 3, MaxLat: 52, MaxLon: 4})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("expected 1 row by prefix, got %d", len(rows))
|
|
}
|
|
rows, _ = srv.queryCoverageRows("aabbccddeeff00112233", bbox{MinLat: 0, MinLon: 0, MaxLat: 1, MaxLon: 1})
|
|
if len(rows) != 0 {
|
|
t.Fatalf("bbox filter failed, got %d", len(rows))
|
|
}
|
|
}
|
|
|
|
func TestMobileRxStats(t *testing.T) {
|
|
db := seedCoverageDB(t)
|
|
mustExecDB(t, db, `INSERT INTO client_receptions (rx_pubkey,heard_key,heard_keylen,snr,lat,lon,rx_at,ingested_at,src) VALUES ('compA','aabbcc',3,-6,51.05,3.72,'t1','t','rxlog')`)
|
|
mustExecDB(t, db, `INSERT INTO client_receptions (rx_pubkey,heard_key,heard_keylen,snr,lat,lon,rx_at,ingested_at,src) VALUES ('compB','aabbcc',3,-8,51.06,3.73,'t2','t','rxlog')`)
|
|
mustExecDB(t, db, `INSERT INTO client_receptions (rx_pubkey,heard_key,heard_keylen,snr,lat,lon,rx_at,ingested_at,src) VALUES ('compA','ffeedd',3,-5,51.07,3.74,'t3','t','rxlog')`)
|
|
srv := &Server{db: db, cfg: &Config{ClientRxCoverage: &ClientRxCoverageConfig{Enabled: true}}}
|
|
c, cl := srv.mobileRxStats("aabbccddeeff00112233")
|
|
if c != 2 || cl != 2 {
|
|
t.Fatalf("got count=%d clients=%d, want 2/2", c, cl)
|
|
}
|
|
}
|
|
|
|
func serveRxCoverage(srv *Server, path string) *httptest.ResponseRecorder {
|
|
router := mux.NewRouter()
|
|
router.HandleFunc("/api/nodes/{pubkey}/rx-coverage", srv.handleNodeRxCoverage).Methods("GET")
|
|
req := httptest.NewRequest("GET", path, nil)
|
|
rr := httptest.NewRecorder()
|
|
router.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
// nodePK is a full 64-hex pubkey whose 3-byte prefix is the seeded heard_key.
|
|
const nodePK = "aabbcc0000000000000000000000000000000000000000000000000000000000"
|
|
|
|
func TestRxCoverageEndpointGeoJSON(t *testing.T) {
|
|
db := seedCoverageDB(t)
|
|
mustExecDB(t, db, `INSERT INTO client_receptions (rx_pubkey,heard_key,heard_keylen,snr,lat,lon,rx_at,ingested_at,src)
|
|
VALUES ('comp','aabbcc',3,-6,51.05,3.72,'t','t','rxlog')`)
|
|
srv := &Server{db: db, cfg: &Config{ClientRxCoverage: &ClientRxCoverageConfig{Enabled: true}}}
|
|
|
|
rr := serveRxCoverage(srv, "/api/nodes/"+nodePK+"/rx-coverage?bbox=50,3,52,4&z=12")
|
|
if rr.Code != 200 {
|
|
t.Fatalf("status %d body %s", rr.Code, rr.Body.String())
|
|
}
|
|
var fc CoverageFeatureCollection
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &fc); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if fc.Type != "FeatureCollection" || len(fc.Features) != 1 {
|
|
t.Fatalf("unexpected fc: %+v", fc)
|
|
}
|
|
// #3: the per-node response carries the node-wide mobile totals (wired in
|
|
// from mobileRxStats). One reception from one companion → 1/1.
|
|
if fc.MobileReceptions != 1 || fc.MobileClients != 1 {
|
|
t.Fatalf("want mobile_receptions=1 mobile_clients=1, got %d/%d", fc.MobileReceptions, fc.MobileClients)
|
|
}
|
|
if serveRxCoverage(srv, "/api/nodes/"+nodePK+"/rx-coverage").Code != 400 {
|
|
t.Fatal("missing bbox should be 400")
|
|
}
|
|
// Non-hex pubkey is rejected up front (parity with handleNodeReach).
|
|
if serveRxCoverage(srv, "/api/nodes/nothex/rx-coverage?bbox=50,3,52,4").Code != 400 {
|
|
t.Fatal("non-hex pubkey should be 400")
|
|
}
|
|
}
|
|
|
|
// TestNodeRxCoverageHidesBlacklistedAndHidden verifies #1727 r2 must-fix #1: the
|
|
// per-node coverage endpoint must 404 for blacklisted or hidden-prefix nodes, so
|
|
// their GPS hex bins / counts aren't retrievable at a pubkey the rest of the API
|
|
// hides — not just the node name.
|
|
func TestNodeRxCoverageHidesBlacklistedAndHidden(t *testing.T) {
|
|
const hidPK = "ddee110000000000000000000000000000000000000000000000000000000000"
|
|
db := seedCoverageDB(t)
|
|
mustExecDB(t, db, `INSERT INTO client_receptions (rx_pubkey,heard_key,heard_keylen,snr,lat,lon,rx_at,ingested_at,src)
|
|
VALUES ('comp','aabbcc',3,-6,51.05,3.72,'t','t','rxlog')`)
|
|
mustExecDB(t, db, `INSERT INTO nodes (public_key,name,role,last_seen,first_seen,advert_count) VALUES ('`+hidPK+`','🚫Secret','repeater','t','t',1)`)
|
|
srv := &Server{db: db, cfg: &Config{
|
|
ClientRxCoverage: &ClientRxCoverageConfig{Enabled: true},
|
|
NodeBlacklist: []string{nodePK},
|
|
HiddenNamePrefixes: []string{"🚫"},
|
|
}}
|
|
|
|
if code := serveRxCoverage(srv, "/api/nodes/"+nodePK+"/rx-coverage?bbox=50,3,52,4").Code; code != 404 {
|
|
t.Fatalf("blacklisted node coverage should be 404, got %d", code)
|
|
}
|
|
if code := serveRxCoverage(srv, "/api/nodes/"+hidPK+"/rx-coverage?bbox=50,3,52,4").Code; code != 404 {
|
|
t.Fatalf("hidden-prefix node coverage should be 404, got %d", code)
|
|
}
|
|
}
|