mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 13:11:40 +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>
74 lines
3.3 KiB
JavaScript
74 lines
3.3 KiB
JavaScript
/* === CoreScope — node-reach-coverage.js ===
|
||
Draws per-node mobile RX coverage as an H3-style hex layer on the existing
|
||
Reach Leaflet map, from the /api/nodes/{pubkey}/rx-coverage GeoJSON.
|
||
No external deps; colours via CSS variables. */
|
||
'use strict';
|
||
(function () {
|
||
// coverageColorVar maps a GeoJSON feature's properties to a CSS variable name.
|
||
// Grey = received but no signal metric; otherwise SF8 SNR thresholds: ≥ −5 green
|
||
// (good margin), −9..−5 orange (near the limit), < −9 red (packet loss likely).
|
||
function coverageColorVar(props) {
|
||
if (!props || !props.has_sig || props.best_snr == null) return '--nq-cov-grey';
|
||
var s = Number(props.best_snr);
|
||
if (s >= -5) return '--nq-cov-strong';
|
||
if (s >= -9) return '--nq-cov-mid';
|
||
return '--nq-cov-weak';
|
||
}
|
||
|
||
function cssColor(varName) {
|
||
try {
|
||
var v = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||
return v || '#888';
|
||
} catch (e) { return '#888'; }
|
||
}
|
||
|
||
// coverageFillOpacity gives the SNR tier a redundant, non-hue cue (stronger =
|
||
// more opaque) so colour-blind users can distinguish tiers (#a11y).
|
||
function coverageFillOpacity(props) {
|
||
switch (coverageColorVar(props)) {
|
||
case '--nq-cov-strong': return 0.6;
|
||
case '--nq-cov-mid': return 0.48;
|
||
case '--nq-cov-weak': return 0.34;
|
||
default: return 0.22;
|
||
}
|
||
}
|
||
|
||
// addLayer fetches coverage for the current map bbox/zoom and draws hex
|
||
// polygons. Returns a handle with off() so the caller can remove it.
|
||
function addLayer(map, pubkey) {
|
||
var group = L.layerGroup().addTo(map);
|
||
function refresh() {
|
||
var b = map.getBounds();
|
||
var bbox = [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].join(',');
|
||
var url = '/api/nodes/' + encodeURIComponent(pubkey) + '/rx-coverage?bbox=' + bbox + '&z=' + map.getZoom();
|
||
fetch(url).then(function (r) { return r.json(); }).then(function (fc) {
|
||
group.clearLayers();
|
||
(fc.features || []).forEach(function (f) {
|
||
var ring = (f.geometry.coordinates[0] || []).map(function (c) { return [c[1], c[0]]; }); // [lon,lat]→[lat,lon]
|
||
var col = cssColor(coverageColorVar(f.properties));
|
||
L.polygon(ring, { color: col, weight: 1, fillColor: col, fillOpacity: coverageFillOpacity(f.properties) })
|
||
.addTo(group)
|
||
.bindTooltip('n=' + f.properties.count +
|
||
(f.properties.best_snr != null ? ' · SNR ' + f.properties.best_snr : ' · no signal'));
|
||
});
|
||
}).catch(function (e) {
|
||
// Leave the layer empty on error; never crash the reach page.
|
||
console.warn('node-reach-coverage: coverage fetch failed', e);
|
||
});
|
||
}
|
||
// Debounce pan/zoom redraws so dragging doesn't storm the coverage endpoint
|
||
// (#6). Keep the reference so off() can unbind the same handler.
|
||
var debouncedRefresh = (typeof debounce === 'function') ? debounce(refresh, 200) : refresh;
|
||
map.on('moveend zoomend', debouncedRefresh);
|
||
refresh();
|
||
return {
|
||
off: function () {
|
||
map.off('moveend zoomend', debouncedRefresh);
|
||
try { map.removeLayer(group); } catch (e) {}
|
||
}
|
||
};
|
||
}
|
||
|
||
window.NodeReachCoverage = { coverageColorVar: coverageColorVar, coverageFillOpacity: coverageFillOpacity, addLayer: addLayer };
|
||
})();
|