mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-04 17:11:37 +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>
264 lines
14 KiB
JavaScript
264 lines
14 KiB
JavaScript
/* === CoreScope — rx-coverage.js ===
|
||
Mobile RX coverage hub (route #/rx-coverage):
|
||
- global H3-style hex coverage map (all mobile observers), time-windowed
|
||
- leaderboard of top mobile observers (companion name + counts)
|
||
- click an observer to filter the map to just their coverage
|
||
Fork-only feature; isolated page (no changes to the core map). */
|
||
'use strict';
|
||
(function () {
|
||
var map = null, covLayer = null, days = 7, selectedRx = '', selectedName = '', boardCache = [], destroyed = false;
|
||
|
||
function cssColor(varName) {
|
||
try { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || '#888'; }
|
||
catch (e) { return '#888'; }
|
||
}
|
||
// SF8 SNR thresholds: ≥ −5 good margin, −9..−5 near the limit, < −9 packet loss
|
||
// likely. Grey = heard but no SNR metric.
|
||
function colorVar(p) {
|
||
if (!p || !p.has_sig || p.best_snr == null) return '--nq-cov-grey';
|
||
var s = Number(p.best_snr);
|
||
if (s >= -5) return '--nq-cov-strong';
|
||
if (s >= -9) return '--nq-cov-mid';
|
||
return '--nq-cov-weak';
|
||
}
|
||
|
||
function dayBtn(d) { return '<button data-days="' + d + '"' + (d === days ? ' class="active"' : '') + ' aria-pressed="' + (d === days ? 'true' : 'false') + '">' + (d === 1 ? '24h' : d + 'd') + '</button>'; }
|
||
|
||
function pageHtml() {
|
||
return '<div style="max-width:1100px;margin:0 auto;padding:12px 16px">' +
|
||
'<h2 style="margin:4px 0 2px;font-size:18px">🗺️ Mobile RX coverage</h2>' +
|
||
'<div style="color:var(--text-muted);font-size:11px">Where roaming CoreScope-RX clients heard nodes. Colour = best signal per cell. <a href="https://github.com/efiten/corescope-rx" target="_blank" rel="noopener">Get the companion app →</a></div>' +
|
||
'<div class="analytics-time-range" id="rxDays" style="margin:8px 0">' + dayBtn(1) + dayBtn(7) + dayBtn(14) + dayBtn(30) + '</div>' +
|
||
'<div class="nq-cov-legend"><span><i style="background:var(--nq-cov-strong)"></i>strong</span><span><i style="background:var(--nq-cov-mid)"></i>medium</span><span><i style="background:var(--nq-cov-weak)"></i>weak</span><span><i style="background:var(--nq-cov-grey)"></i>no signal</span></div>' +
|
||
'<div id="rxMap" style="height:60vh;min-height:360px;border:1px solid var(--border,#d0d7de);border-radius:6px;margin:8px 0"></div>' +
|
||
'<div class="nq-group-h">Top mobile observers</div>' +
|
||
'<div id="rxBoard" class="rxb"></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
// coverageNodeRow renders one heard node: name (or heard_key prefix) + latest SNR + count.
|
||
function coverageNodeRow(n) {
|
||
var label = n.name ? escapeHtml(n.name) : '<code>' + escapeHtml(n.prefix || '?') + '</code>';
|
||
var snr = (n.snr != null) ? Number(n.snr).toFixed(1) + ' dB' : 'no sig';
|
||
return '<div style="display:flex;gap:8px;justify-content:space-between;font-size:12px;line-height:1.6">' +
|
||
'<span>' + label + '</span>' +
|
||
'<span style="color:var(--text-muted)">' + snr + ' · ×' + n.count + '</span></div>';
|
||
}
|
||
|
||
// coverageNodesHtml lists the nodes directly heard in a cell (properties.nodes:
|
||
// {prefix, name, snr, count}, strongest latest-SNR first; prefix shown when the
|
||
// name is unresolved). Rendered in the hover tooltip; capped at 10 rows with a
|
||
// "(N more)" footer so dense cells don't produce an unwieldy tooltip.
|
||
var COVERAGE_NODE_CAP = 10;
|
||
function coverageNodesHtml(p) {
|
||
var nodes = (p && p.nodes) || [];
|
||
var head = '<div style="font-weight:600;margin-bottom:4px">' +
|
||
nodes.length + (nodes.length === 1 ? ' node heard here' : ' nodes heard here') + '</div>';
|
||
if (!nodes.length) return head + '<div style="color:var(--text-muted)">n=' + (p ? p.count : 0) + '</div>';
|
||
var rows = nodes.slice(0, COVERAGE_NODE_CAP).map(coverageNodeRow).join('');
|
||
var more = (nodes.length > COVERAGE_NODE_CAP)
|
||
? '<div style="color:var(--text-muted);font-size:11px;margin-top:3px">(' + (nodes.length - COVERAGE_NODE_CAP) + ' more)</div>'
|
||
: '';
|
||
return head + '<div style="min-width:180px">' + rows + '</div>' + more;
|
||
}
|
||
|
||
// fillOpacityFor adds a redundant, non-hue cue to the SNR tier so the map is
|
||
// distinguishable for colour-blind users (orange vs red): stronger signal =
|
||
// more opaque. Pairs with the hue and the per-cell SNR in the tooltip (#a11y).
|
||
function fillOpacityFor(p) {
|
||
switch (colorVar(p)) {
|
||
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;
|
||
}
|
||
}
|
||
|
||
function drawCoverage() {
|
||
if (!map || destroyed) return;
|
||
var b = map.getBounds();
|
||
var bbox = [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].join(',');
|
||
var url = '/api/rx-coverage?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : '');
|
||
fetch(url).then(function (r) { return r.json(); }).then(function (fc) {
|
||
if (destroyed || !covLayer) return;
|
||
covLayer.clearLayers();
|
||
(fc.features || []).forEach(function (f) {
|
||
var ring = (f.geometry.coordinates[0] || []).map(function (c) { return [c[1], c[0]]; });
|
||
var col = cssColor(colorVar(f.properties));
|
||
L.polygon(ring, { color: col, weight: 1, fillColor: col, fillOpacity: fillOpacityFor(f.properties) }).addTo(covLayer)
|
||
.bindTooltip(coverageNodesHtml(f.properties));
|
||
});
|
||
}).catch(function (e) { console.warn('rx-coverage: coverage fetch failed', e); });
|
||
}
|
||
|
||
// Leaderboard sort state. Default = frontier score, descending. The rank (#)
|
||
// column is not sortable (it just reflects the current order). Numeric columns
|
||
// default to descending on first click; the name column to ascending.
|
||
var boardSort = { key: 'score', dir: 'desc' };
|
||
var BOARD_COLS = [
|
||
{ key: 'name', label: 'Observer (companion)', cls: 'rxb-name' },
|
||
{ key: 'score', label: 'score', cls: 'rxb-score',
|
||
title: 'Score telt je gedekte cellen, waarbij elke cel zwaarder weegt naarmate minder andere waarnemers ze bereikt hebben — grensverleggende dekking weegt meer dan drukke zones opnieuw afrijden.' },
|
||
{ key: 'cells', label: 'cells', cls: 'rxb-cells',
|
||
title: 'Aantal unieke ~150 m-cellen waar deze waarnemer iets hoorde.' },
|
||
{ key: 'nodes', label: 'nodes', cls: 'rxb-nodes' },
|
||
{ key: 'receptions', label: 'pkts', cls: 'rxb-rec' }
|
||
];
|
||
|
||
function sortBoard() {
|
||
var k = boardSort.key, dir = boardSort.dir === 'asc' ? 1 : -1;
|
||
boardCache.sort(function (a, b) {
|
||
if (k === 'name') {
|
||
var an = (a.name || a.pubkey).toLowerCase(), bn = (b.name || b.pubkey).toLowerCase();
|
||
return an < bn ? -dir : an > bn ? dir : 0;
|
||
}
|
||
return (Number(a[k]) - Number(b[k])) * dir;
|
||
});
|
||
}
|
||
|
||
function boardHeadHtml() {
|
||
var cells = BOARD_COLS.map(function (c) {
|
||
var arrow = boardSort.key === c.key ? (boardSort.dir === 'asc' ? ' ▲' : ' ▼') : '';
|
||
return '<span class="' + c.cls + ' rxb-sort" data-sort="' + c.key + '"' +
|
||
(c.title ? ' title="' + escapeHtml(c.title) + '"' : '') +
|
||
' role="button" tabindex="0">' + escapeHtml(c.label) + arrow + '</span>';
|
||
}).join('');
|
||
return '<div class="rxb-row rxb-head"><span class="rxb-rank">#</span>' + cells + '</div>';
|
||
}
|
||
|
||
function renderBoard() {
|
||
var el = document.getElementById('rxBoard');
|
||
if (!el) return;
|
||
if (!boardCache.length) { el.innerHTML = '<div class="muted" style="color:var(--text-muted);font-size:13px">No mobile observers in this window yet.</div>'; return; }
|
||
sortBoard();
|
||
var rows = boardCache.map(function (o, i) {
|
||
var nm = o.name ? escapeHtml(o.name) : (escapeHtml(o.pubkey.slice(0, 10)) + '…');
|
||
return '<div class="rxb-row' + (o.pubkey === selectedRx ? ' sel' : '') + '" data-rx="' + escapeHtml(o.pubkey) + '" data-name="' + escapeHtml(o.name || '') + '"' +
|
||
' role="button" tabindex="0" aria-pressed="' + (o.pubkey === selectedRx ? 'true' : 'false') + '" aria-label="Show coverage for ' + escapeHtml(o.name || o.pubkey.slice(0, 10)) + '">' +
|
||
'<span class="rxb-rank">' + (i + 1) + '</span><span class="rxb-name">' + nm + '</span>' +
|
||
'<span class="rxb-score">' + Number(o.score).toFixed(1) + '</span>' +
|
||
'<span class="rxb-cells">' + o.cells + '</span>' +
|
||
'<span class="rxb-nodes">' + o.nodes + '</span>' +
|
||
'<span class="rxb-rec">' + o.receptions + '</span></div>';
|
||
}).join('');
|
||
el.innerHTML = (selectedRx ? '<button id="rxAll" class="btn-primary" style="margin:0 0 8px">← Show all observers</button>' : '') +
|
||
boardHeadHtml() + rows;
|
||
// Column sort handlers (click + keyboard).
|
||
el.querySelectorAll('.rxb-sort[data-sort]').forEach(function (h) {
|
||
function applySort() {
|
||
var k = h.dataset.sort;
|
||
if (boardSort.key === k) {
|
||
boardSort.dir = boardSort.dir === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
boardSort.key = k;
|
||
boardSort.dir = (k === 'name') ? 'asc' : 'desc';
|
||
}
|
||
renderBoard();
|
||
}
|
||
h.addEventListener('click', applySort);
|
||
h.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { e.preventDefault(); applySort(); }
|
||
});
|
||
});
|
||
// Row click-to-filter (preserved from the original).
|
||
el.querySelectorAll('.rxb-row[data-rx]').forEach(function (r) {
|
||
function activate() {
|
||
selectedRx = r.dataset.rx; selectedName = r.dataset.name || '';
|
||
renderBoard(); fitToObserver(); syncHash();
|
||
}
|
||
r.addEventListener('click', activate);
|
||
r.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { e.preventDefault(); activate(); }
|
||
});
|
||
});
|
||
var all = document.getElementById('rxAll');
|
||
if (all) all.addEventListener('click', function () { selectedRx = ''; selectedName = ''; renderBoard(); drawCoverage(); syncHash(); });
|
||
}
|
||
|
||
// fitToObserver zooms the map to the selected observer's full coverage extent
|
||
// (fetched with a world bbox so it's independent of the current view), then the
|
||
// resulting moveend redraws the hexes at the fitted resolution.
|
||
function fitToObserver() {
|
||
if (!map || !selectedRx) { drawCoverage(); return; }
|
||
var url = '/api/rx-coverage?bbox=-90,-180,90,180&z=' + Math.max(8, map.getZoom()) + '&days=' + days + '&rx=' + encodeURIComponent(selectedRx);
|
||
fetch(url).then(function (r) { return r.json(); }).then(function (fc) {
|
||
if (destroyed || !map) return;
|
||
var minLat = 90, minLon = 180, maxLat = -90, maxLon = -180, any = false;
|
||
(fc.features || []).forEach(function (f) {
|
||
(f.geometry.coordinates[0] || []).forEach(function (c) {
|
||
any = true;
|
||
if (c[1] < minLat) minLat = c[1]; if (c[1] > maxLat) maxLat = c[1];
|
||
if (c[0] < minLon) minLon = c[0]; if (c[0] > maxLon) maxLon = c[0];
|
||
});
|
||
});
|
||
if (!any) { drawCoverage(); return; } // observer has no data in window → keep view
|
||
map.fitBounds([[minLat, minLon], [maxLat, maxLon]], { padding: [30, 30], maxZoom: 15 });
|
||
drawCoverage(); // fitBounds may not fire moveend if the view is unchanged
|
||
}).catch(function (e) { console.warn('rx-coverage: observer extent fetch failed', e); drawCoverage(); });
|
||
}
|
||
|
||
function loadBoard() {
|
||
fetch('/api/rx-leaderboard?days=' + days + '&limit=25').then(function (r) { return r.json(); })
|
||
.then(function (d) { if (destroyed) return; boardCache = d.observers || []; renderBoard(); })
|
||
.catch(function (e) {
|
||
console.warn('rx-coverage: leaderboard fetch failed', e);
|
||
var el = document.getElementById('rxBoard');
|
||
if (el) el.innerHTML = '<div class="muted" style="color:var(--text-muted);font-size:13px">Could not load mobile observers.</div>';
|
||
});
|
||
}
|
||
|
||
function setDays(d) {
|
||
days = d;
|
||
var bar = document.getElementById('rxDays');
|
||
if (bar) bar.querySelectorAll('button').forEach(function (b) { b.classList.toggle('active', +b.dataset.days === d); });
|
||
loadBoard(); drawCoverage(); syncHash();
|
||
}
|
||
|
||
function syncHash() {
|
||
var q = 'days=' + days + (selectedRx ? '&rx=' + selectedRx : '');
|
||
try { history.replaceState(null, '', '#/rx-coverage?' + q); } catch (e) {}
|
||
}
|
||
|
||
function init(container) {
|
||
destroyed = false;
|
||
// A direct land on #/rx-coverage can run before MeshConfigReady resolves, at
|
||
// which point MC_CLIENT_RX_COVERAGE is still undefined and the page would
|
||
// wrongly show "not enabled". Defer until server config is loaded (#13).
|
||
Promise.resolve(window.MeshConfigReady).then(function () {
|
||
if (!destroyed) start(container);
|
||
});
|
||
}
|
||
|
||
function start(container) {
|
||
if (!window.MC_CLIENT_RX_COVERAGE) {
|
||
container.innerHTML = '<div class="nq-msg">Coverage is not enabled on this deployment.</div>';
|
||
return;
|
||
}
|
||
selectedRx = ''; selectedName = ''; days = 7; boardCache = [];
|
||
try {
|
||
var p = (typeof getHashParams === 'function') ? getHashParams() : null;
|
||
if (p) { var dd = parseInt(p.get('days'), 10); if ([1, 7, 14, 30].indexOf(dd) >= 0) days = dd; selectedRx = (p.get('rx') || '').toLowerCase(); }
|
||
} catch (e) {}
|
||
container.innerHTML = pageHtml();
|
||
map = L.map('rxMap', { zoomControl: true, attributionControl: false }).setView([51.0, 4.8], 8);
|
||
if (typeof window._applyTilesToNodeMap === 'function') window._applyTilesToNodeMap(map);
|
||
else L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
|
||
covLayer = L.layerGroup().addTo(map);
|
||
// Debounce pan/zoom redraws so dragging the map doesn't fire a storm of
|
||
// /api/rx-coverage requests (#6). Direct calls (setDays, fit) stay immediate.
|
||
map.on('moveend zoomend', debounce(drawCoverage, 200));
|
||
var bar = document.getElementById('rxDays');
|
||
if (bar) bar.addEventListener('click', function (e) { var b = e.target.closest('button[data-days]'); if (b) setDays(+b.dataset.days); });
|
||
setTimeout(function () { if (!destroyed && map) { map.invalidateSize(); if (selectedRx) fitToObserver(); else drawCoverage(); } }, 150);
|
||
loadBoard();
|
||
}
|
||
|
||
function destroy() {
|
||
destroyed = true;
|
||
if (map) { try { map.remove(); } catch (e) {} map = null; }
|
||
covLayer = null;
|
||
}
|
||
|
||
registerPage('rx-coverage', { init: init, destroy: destroy });
|
||
})();
|