mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 04:52:30 +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>
120 lines
4.0 KiB
Go
120 lines
4.0 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Pure-Go hexagonal binning for RX coverage display. We deliberately avoid the
|
|
// CGO-based uber/h3-go (this project builds with CGO_ENABLED=0). Points are
|
|
// projected to Web Mercator and snapped to a pointy-top hex grid whose size
|
|
// depends on the display resolution. Cell ids are "res:q:r" (axial coords).
|
|
// At city/region scale this looks like H3/mapme.sh coverage without any deps.
|
|
|
|
const hexEarthRadius = 6378137.0 // Web Mercator sphere radius (m)
|
|
|
|
// hexTargetPx is the desired on-screen hex size (point-to-point height) in CSS
|
|
// pixels. mercUPPZ0 is Web Mercator units per pixel at zoom 0 (world span / 256);
|
|
// Leaflet halves it each zoom level, independent of latitude. Sizing the hex in
|
|
// these units therefore renders it at a constant ~hexTargetPx at every zoom — the
|
|
// old fixed-meter buckets looked like specks when zoomed out (issue: hexes too small).
|
|
const hexTargetPx = 28.0
|
|
const mercUPPZ0 = 156543.03392
|
|
|
|
func hexMercator(lat, lon float64) (float64, float64) {
|
|
x := hexEarthRadius * lon * math.Pi / 180
|
|
y := hexEarthRadius * math.Log(math.Tan(math.Pi/4+lat*math.Pi/360))
|
|
return x, y
|
|
}
|
|
|
|
func hexInvMercator(x, y float64) (lat, lon float64) {
|
|
lon = x / hexEarthRadius * 180 / math.Pi
|
|
lat = (2*math.Atan(math.Exp(y/hexEarthRadius)) - math.Pi/2) * 180 / math.Pi
|
|
return lat, lon
|
|
}
|
|
|
|
// hexSizeForRes is the hex circumradius (center→corner) in Web Mercator units for a
|
|
// display resolution. Resolution equals the Leaflet zoom level (see zoomToHexRes), so
|
|
// the size scales as 2^-zoom and the hex keeps a constant ~hexTargetPx on-screen size
|
|
// regardless of zoom. hexCellAt (binning) and hexBoundary (drawing) both read this, so
|
|
// they stay consistent for a given cell id.
|
|
func hexSizeForRes(res int) float64 {
|
|
return (hexTargetPx / 2) * mercUPPZ0 / math.Pow(2, float64(res))
|
|
}
|
|
|
|
// hexMaxLat is the Web Mercator latitude limit. The projection (hexMercator)
|
|
// diverges toward ±90° — tan(π/4 + lat·π/360) → ∞ — so points beyond this would
|
|
// produce NaN cell rings via hexInvMercator. Coverage is therefore only defined
|
|
// within ±hexMaxLat; polar submissions are clamped to the edge (#17).
|
|
const hexMaxLat = 85.05112878
|
|
|
|
// hexCellAt returns a stable cell id ("res:q:r") for the lat/lon at res. Latitude
|
|
// is clamped to ±hexMaxLat so near-polar points bin to the edge instead of
|
|
// producing NaN geometry.
|
|
func hexCellAt(lat, lon float64, res int) string {
|
|
if lat > hexMaxLat {
|
|
lat = hexMaxLat
|
|
} else if lat < -hexMaxLat {
|
|
lat = -hexMaxLat
|
|
}
|
|
size := hexSizeForRes(res)
|
|
x, y := hexMercator(lat, lon)
|
|
q := (math.Sqrt(3)/3*x - 1.0/3*y) / size
|
|
r := (2.0 / 3 * y) / size
|
|
qi, ri := hexRound(q, r)
|
|
return fmt.Sprintf("%d:%d:%d", res, qi, ri)
|
|
}
|
|
|
|
// hexRound rounds fractional axial coords to the nearest hex via cube rounding.
|
|
func hexRound(q, r float64) (int, int) {
|
|
x, z := q, r
|
|
y := -x - z
|
|
rx, ry, rz := math.Round(x), math.Round(y), math.Round(z)
|
|
dx, dy, dz := math.Abs(rx-x), math.Abs(ry-y), math.Abs(rz-z)
|
|
switch {
|
|
case dx > dy && dx > dz:
|
|
rx = -ry - rz
|
|
case dy > dz:
|
|
ry = -rx - rz
|
|
default:
|
|
rz = -rx - ry
|
|
}
|
|
return int(rx), int(rz)
|
|
}
|
|
|
|
// hexBoundary returns the cell's 6 corners as a closed [lon,lat] ring (GeoJSON
|
|
// order), or nil if the cell id is malformed.
|
|
func hexBoundary(cellID string) [][2]float64 {
|
|
res, q, r, ok := parseHexCell(cellID)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
size := hexSizeForRes(res)
|
|
cx := size * (math.Sqrt(3)*float64(q) + math.Sqrt(3)/2*float64(r))
|
|
cy := size * (1.5 * float64(r))
|
|
ring := make([][2]float64, 0, 7)
|
|
for i := 0; i < 6; i++ {
|
|
ang := math.Pi / 180 * float64(60*i-30)
|
|
lat, lon := hexInvMercator(cx+size*math.Cos(ang), cy+size*math.Sin(ang))
|
|
ring = append(ring, [2]float64{lon, lat})
|
|
}
|
|
ring = append(ring, ring[0]) // close the ring
|
|
return ring
|
|
}
|
|
|
|
func parseHexCell(id string) (res, q, r int, ok bool) {
|
|
p := strings.Split(id, ":")
|
|
if len(p) != 3 {
|
|
return 0, 0, 0, false
|
|
}
|
|
a, e1 := strconv.Atoi(p[0])
|
|
b, e2 := strconv.Atoi(p[1])
|
|
c, e3 := strconv.Atoi(p[2])
|
|
if e1 != nil || e2 != nil || e3 != nil {
|
|
return 0, 0, 0, false
|
|
}
|
|
return a, b, c, true
|
|
}
|