mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-20 19:00:58 +00:00
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>
89 lines
3.8 KiB
Go
89 lines
3.8 KiB
Go
package main
|
|
|
|
import "testing"
|
|
|
|
// testCompanionPK is a valid lowercase-hex companion pubkey for coverage tests.
|
|
// The topic segment must be hex (clientPubkeyRe) or handleClientPacket drops it.
|
|
const testCompanionPK = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
|
|
|
|
// clientCoverageMsg builds a valid mobile client-RX coverage message on the
|
|
// dedicated topic meshcore/client/<pubkey>/packets. The raw hex is a relayed
|
|
// advert with GPS, so handleClientPacket would write exactly one
|
|
// client_receptions row when the feature is enabled (see
|
|
// TestHandleClientPacketAdvertWritesReception).
|
|
func clientCoverageMsg() *mockMessage {
|
|
advertHex := "11451000D818206D3AAC152C8A91F89957E6D30CA51F36E28790228971C473B755F244F718754CF5EE4A2FD58D944466E42CDED140C66D0CC590183E32BAF40F112BE8F3F2BDF6012B4B2793C52F1D36F69EE054D9A05593286F78453E56C0EC4A3EB95DDA2A7543FCCC00B939CACC009278603902FC12BCF84B706120526F6F6620536F6C6172"
|
|
payload := []byte(`{"raw":"` + advertHex + `","direction":"rx","timestamp":"2026-06-09T12:00:00Z","origin":"MyMob","SNR":-7.0,"RSSI":-92.0,"gps":{"lat":51.05,"lon":3.72,"acc_m":8.0}}`)
|
|
return &mockMessage{topic: "meshcore/client/" + testCompanionPK + "/packets", payload: payload}
|
|
}
|
|
|
|
func clientReceptionCount(t *testing.T, s *Store) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM client_receptions`).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// TestClientRxCoverageEnabledDefault verifies the gate helper defaults OFF for
|
|
// nil/absent config and is only true when explicitly enabled.
|
|
func TestClientRxCoverageEnabledDefault(t *testing.T) {
|
|
if (&Config{}).ClientRxCoverageEnabled() {
|
|
t.Fatal("nil ClientRxCoverage must report disabled")
|
|
}
|
|
if (&Config{ClientRxCoverage: &ClientRxCoverageConfig{Enabled: false}}).ClientRxCoverageEnabled() {
|
|
t.Fatal("Enabled:false must report disabled")
|
|
}
|
|
if !(&Config{ClientRxCoverage: &ClientRxCoverageConfig{Enabled: true}}).ClientRxCoverageEnabled() {
|
|
t.Fatal("Enabled:true must report enabled")
|
|
}
|
|
}
|
|
|
|
// TestClientRxCoverageGateOff drives handleMessage with the feature OFF: the
|
|
// client-topic message must fall through and write no client_receptions rows.
|
|
func TestClientRxCoverageGateOff(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
cfg := &Config{} // ClientRxCoverage nil ⇒ disabled
|
|
|
|
handleMessage(store, "test", source, clientCoverageMsg(), nil, nil, cfg)
|
|
|
|
if n := clientReceptionCount(t, store); n != 0 {
|
|
t.Fatalf("feature OFF: expected 0 client_receptions rows, got %d", n)
|
|
}
|
|
}
|
|
|
|
// TestClientRxCoverageGateOn drives handleMessage with the feature ON: the
|
|
// client-topic message must be dispatched and write exactly one row.
|
|
func TestClientRxCoverageGateOn(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
cfg := &Config{ClientRxCoverage: &ClientRxCoverageConfig{Enabled: true}}
|
|
|
|
handleMessage(store, "test", source, clientCoverageMsg(), nil, nil, cfg)
|
|
|
|
if n := clientReceptionCount(t, store); n != 1 {
|
|
t.Fatalf("feature ON: expected 1 client_receptions row, got %d", n)
|
|
}
|
|
}
|
|
|
|
// TestClientRxCoverageBlacklistedDropped verifies the #1 fix: a blacklisted
|
|
// operator cannot skirt the observer blacklist via the client topic. With the
|
|
// feature ON but the companion pubkey blacklisted, no row is written. Without
|
|
// the gate the client dispatch runs before the blacklist check and inserts.
|
|
func TestClientRxCoverageBlacklistedDropped(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := MQTTSource{Name: "test"}
|
|
cfg := &Config{
|
|
ClientRxCoverage: &ClientRxCoverageConfig{Enabled: true},
|
|
ObserverBlacklist: []string{testCompanionPK},
|
|
}
|
|
|
|
handleMessage(store, "test", source, clientCoverageMsg(), nil, nil, cfg)
|
|
|
|
if n := clientReceptionCount(t, store); n != 0 {
|
|
t.Fatalf("blacklisted companion: expected 0 client_receptions rows, got %d", n)
|
|
}
|
|
}
|