Files
meshcore-analyzer/test-node-reach-coverage-e2e.js
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

66 lines
3.0 KiB
JavaScript

// E2E for the RX coverage hex layer on the Reach page (#/nodes/<pubkey>/reach?coverage=1).
// Defaults to localhost:3000 — NEVER point at prod (AGENTS.md). CI sets BASE_URL.
const { chromium } = require('playwright');
const BASE = process.env.BASE_URL || 'http://localhost:3000';
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
// Coverage is opt-in (config flag, default off). Skip when the deployment under
// test hasn't enabled it — the endpoints 404 and the UI toggle is absent by design.
const clientCfg = await (await page.request.get(BASE + '/api/config/client')).json();
if (clientCfg.clientRxCoverage !== true) {
console.log('node-reach-coverage E2E SKIP (clientRxCoverage disabled on this deployment)');
await browser.close();
return;
}
const nodes = await (await page.request.get(BASE + '/api/nodes?role=repeater&limit=1')).json();
if (!nodes.nodes || !nodes.nodes.length) {
console.log('node-reach-coverage E2E SKIP (no repeater in dataset)');
await browser.close();
return;
}
const pk = nodes.nodes[0].public_key;
// 1. The coverage endpoint returns a GeoJSON FeatureCollection.
const cov = await (await page.request.get(
BASE + '/api/nodes/' + pk + '/rx-coverage?bbox=-90,-180,90,180&z=10')).json();
if (cov.type !== 'FeatureCollection' || !Array.isArray(cov.features)) {
throw new Error('rx-coverage must return a FeatureCollection with a features array');
}
// 2. Bad bbox → 400.
const bad = await page.request.get(BASE + '/api/nodes/' + pk + '/rx-coverage');
if (bad.status() !== 400) throw new Error('missing bbox should be 400, got ' + bad.status());
// 3. The Reach page exposes the coverage toggle.
await page.goto(BASE + '/#/nodes/' + pk + '/reach');
await page.waitForSelector('.nq-head', { timeout: 20000 });
const reach = await (await page.request.get(BASE + '/api/nodes/' + pk + '/reach?days=7')).json();
if (reach.reliable_tokens && reach.reliable_tokens.length && (await page.locator('#nqRows').count())) {
await page.waitForSelector('#nqCoverage');
// 4. Enabling coverage issues a request to rx-coverage, shows the legend, and deep-links.
const waitCov = page.waitForRequest((r) => r.url().includes('/rx-coverage'), { timeout: 15000 });
await page.check('#nqCoverage');
await waitCov;
await page.waitForSelector('#nqCovLegend', { state: 'visible' });
if (!/coverage=1/.test(await page.evaluate(() => location.hash))) {
throw new Error('coverage toggle did not deep-link ?coverage=1');
}
// 5. Toggling off hides the legend.
await page.uncheck('#nqCoverage');
await page.waitForSelector('#nqCovLegend', { state: 'hidden' });
}
const errors = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
if (errors.length) throw new Error('console errors: ' + errors.join('; '));
console.log('node-reach-coverage E2E OK');
await browser.close();
})().catch((e) => { console.error('node-reach-coverage E2E FAIL:', e.message); process.exit(1); });