mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-26 18:27: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>
224 lines
8.0 KiB
Go
224 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"log"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/meshcore-analyzer/packetpath"
|
|
)
|
|
|
|
// clientPubkeyRe validates the companion pubkey taken from the MQTT topic
|
|
// (meshcore/client/<PUBLIC_KEY>/packets). A no-ACL broker would let a client
|
|
// publish under an arbitrary topic segment (e.g. "!@#$"), so we reject anything
|
|
// that is not lowercase hex before it reaches client_receptions/client_observers.
|
|
// Mirrors the server-side hexPrefixRe (cmd/server/node_resolve.go).
|
|
var clientPubkeyRe = regexp.MustCompile(`^[0-9a-f]{2,64}$`)
|
|
|
|
// handleClientPacket processes a packet from the mobile client RX topic
|
|
// (meshcore/client/{PUBLIC_KEY}/packets). Unlike observer packets, a roaming
|
|
// companion reports WHERE it directly heard a node, so we write a
|
|
// client_receptions row and never touch the observers/observations tables.
|
|
// rxPubkey is the companion pubkey from the topic (ACL-bound by the broker).
|
|
func handleClientPacket(store *Store, tag, rxPubkey string, msg map[string]interface{}, channelKeys map[string]string) {
|
|
// The companion identity IS the (ACL-bound) topic pubkey. Reject non-hex
|
|
// topic segments so a no-ACL broker can't pollute the coverage tables, and
|
|
// never fall back to a payload-supplied id (that would defeat the ACL trust
|
|
// model — see docs/client-rx-coverage.md).
|
|
rxPubkey = strings.ToLower(strings.TrimSpace(rxPubkey))
|
|
if !clientPubkeyRe.MatchString(rxPubkey) {
|
|
log.Printf("MQTT [%s] client: invalid pubkey %.8q, dropping", tag, rxPubkey)
|
|
return
|
|
}
|
|
rawHex, _ := msg["raw"].(string)
|
|
if rawHex == "" {
|
|
return
|
|
}
|
|
gps, ok := msg["gps"].(map[string]interface{})
|
|
if !ok {
|
|
return // a client packet without a GPS fix is not coverage; drop
|
|
}
|
|
lat, latOK := toFloat64(gps["lat"])
|
|
lon, lonOK := toFloat64(gps["lon"])
|
|
if !latOK || !lonOK {
|
|
return
|
|
}
|
|
var accPtr *float64
|
|
if acc, ok := toFloat64(gps["acc_m"]); ok {
|
|
accPtr = &acc
|
|
}
|
|
|
|
decoded, err := DecodePacket(rawHex, channelKeys, false)
|
|
if err != nil {
|
|
log.Printf("MQTT [%s] client decode error: %v", tag, err)
|
|
return
|
|
}
|
|
|
|
direction := ""
|
|
if v, ok := msg["direction"].(string); ok {
|
|
direction = v
|
|
} else if v, ok := msg["Direction"].(string); ok {
|
|
direction = v
|
|
}
|
|
|
|
var snrPtr *float64
|
|
if f, ok := toFloat64(firstPresent(msg, "SNR", "snr")); ok {
|
|
snrPtr = &f
|
|
}
|
|
var rssiPtr *int
|
|
if f, ok := toFloat64(firstPresent(msg, "RSSI", "rssi")); ok {
|
|
v := int(f)
|
|
rssiPtr = &v
|
|
}
|
|
|
|
rxAt, _ := resolveRxTime(msg, tag)
|
|
isAdvert := decoded.Header.PayloadTypeName == "ADVERT"
|
|
|
|
rec, ok := buildClientReception(
|
|
rxPubkey,
|
|
direction, decoded.Header.RouteType, decoded.Path.Hops, decoded.Payload.PubKey, isAdvert,
|
|
snrPtr, rssiPtr, lat, lon, accPtr, rxAt, time.Now().UTC().Format(time.RFC3339),
|
|
)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := store.InsertClientReception(rec); err != nil {
|
|
log.Printf("MQTT [%s] client_reception insert: %v", tag, err)
|
|
}
|
|
// Remember the companion's self-reported name (sent as "origin") so the
|
|
// leaderboard can show a name even if this companion never advertised.
|
|
if name := stringField(msg, "origin"); name != "" {
|
|
if err := store.UpsertClientObserver(rec.RxPubkey, name, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
|
log.Printf("MQTT [%s] client_observer upsert: %v", tag, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// UpsertClientObserver records/updates a mobile client's self-reported name.
|
|
// All writes live in the ingestor (read/write invariant #1283).
|
|
func (s *Store) UpsertClientObserver(pubkey, name, ts string) error {
|
|
if pubkey == "" || name == "" {
|
|
return nil
|
|
}
|
|
_, err := s.db.Exec(`
|
|
INSERT INTO client_observers (pubkey, name, last_seen) VALUES (?,?,?)
|
|
ON CONFLICT(pubkey) DO UPDATE SET name = excluded.name, last_seen = excluded.last_seen`,
|
|
strings.ToLower(pubkey), name, ts)
|
|
return err
|
|
}
|
|
|
|
// firstPresent returns the first present value among the given keys.
|
|
func firstPresent(msg map[string]interface{}, keys ...string) interface{} {
|
|
for _, k := range keys {
|
|
if v, ok := msg[k]; ok {
|
|
return v
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// stringField returns msg[key] as a string, or "" if absent/not a string.
|
|
func stringField(msg map[string]interface{}, key string) string {
|
|
if v, ok := msg[key].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ClientReception is one mobile RX coverage point: a companion (RxPubkey)
|
|
// directly heard a node (HeardKey) at a GPS position. Hex binning is done
|
|
// server-side from Lat/Lon at query time, so no cell id is stored here.
|
|
type ClientReception struct {
|
|
RxPubkey string
|
|
HeardKey string
|
|
HeardKeyLen int
|
|
RSSI *int
|
|
SNR *float64
|
|
Lat float64
|
|
Lon float64
|
|
PosAccM *float64
|
|
RxAt string
|
|
IngestedAt string
|
|
Src string
|
|
}
|
|
|
|
// deriveHeardKey applies the RX capture HARD RULE: record only what the
|
|
// companion heard itself and directly.
|
|
// - direction must be "rx".
|
|
// - hops present AND a FLOOD route → the directly-heard node is the LAST hop
|
|
// (path[len-1] = the forwarder that just transmitted; each FLOOD forwarder
|
|
// appends its hash to the end). 1-byte (2 hex char) prefixes are rejected.
|
|
// - hops present on a DIRECT route → NOT attributable: direct forwarders
|
|
// consume the next hop from the FRONT (firmware Mesh.cpp removeSelfFromPath),
|
|
// so path[len-1] is the route's destination-side end, not who was heard.
|
|
// - hops empty + isAdvert → the 0-hop advertiser, by its full pubkey.
|
|
// - otherwise → not attributable (ok=false).
|
|
//
|
|
// Returns (heardKey lowercased, keylenBytes, src, ok).
|
|
func deriveHeardKey(direction string, routeType int, hops []string, advertPubkey string, isAdvert bool) (string, int, string, bool) {
|
|
if !strings.EqualFold(direction, "rx") {
|
|
return "", 0, "", false
|
|
}
|
|
if len(hops) > 0 {
|
|
// FLOOD routes (TRANSPORT_FLOOD 0, FLOOD 1) APPEND each forwarder's hash to
|
|
// the END of the path, so path[last] is the immediate RF transmitter. DIRECT
|
|
// routes (2, 3) consume the next hop from the FRONT, so path[last] is the
|
|
// route's destination-side end, NOT who was heard.
|
|
if routeType != packetpath.RouteTransportFlood && routeType != packetpath.RouteFlood { // direct route: path[last] is not the transmitter
|
|
return "", 0, "", false
|
|
}
|
|
last := strings.ToLower(strings.TrimSpace(hops[len(hops)-1]))
|
|
keylen := len(last) / 2
|
|
if keylen < 2 { // exclude 1-byte (collision-prone), matching Reach
|
|
return "", 0, "", false
|
|
}
|
|
return last, keylen, "rxlog", true
|
|
}
|
|
if isAdvert && advertPubkey != "" {
|
|
pk := strings.ToLower(strings.TrimSpace(advertPubkey))
|
|
return pk, len(pk) / 2, "advert", true
|
|
}
|
|
return "", 0, "", false
|
|
}
|
|
|
|
// buildClientReception validates inputs and assembles a ClientReception, or
|
|
// returns ok=false when the packet is not attributable / out of range.
|
|
func buildClientReception(
|
|
rxPubkey, direction string, routeType int, hops []string, advertPubkey string, isAdvert bool,
|
|
snr *float64, rssi *int, lat, lon float64, posAccM *float64, rxAt, ingestedAt string,
|
|
) (*ClientReception, bool) {
|
|
if rxPubkey == "" || rxAt == "" {
|
|
return nil, false
|
|
}
|
|
if lat < -90 || lat > 90 || lon < -180 || lon > 180 {
|
|
return nil, false
|
|
}
|
|
heardKey, keylen, src, ok := deriveHeardKey(direction, routeType, hops, advertPubkey, isAdvert)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return &ClientReception{
|
|
RxPubkey: strings.ToLower(rxPubkey), HeardKey: heardKey, HeardKeyLen: keylen,
|
|
RSSI: rssi, SNR: snr, Lat: lat, Lon: lon, PosAccM: posAccM,
|
|
RxAt: rxAt, IngestedAt: ingestedAt, Src: src,
|
|
}, true
|
|
}
|
|
|
|
// InsertClientReception writes one coverage row. Idempotent via the
|
|
// UNIQUE(rx_pubkey, heard_key, rx_at) constraint; returns ins=false when the
|
|
// row already existed. All writes live in the ingestor (read/write invariant #1283).
|
|
func (s *Store) InsertClientReception(r *ClientReception) (bool, error) {
|
|
res, err := s.db.Exec(`
|
|
INSERT INTO client_receptions
|
|
(rx_pubkey, heard_key, heard_keylen, rssi, snr, lat, lon, pos_acc_m, rx_at, ingested_at, src)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(rx_pubkey, heard_key, rx_at) DO NOTHING`,
|
|
r.RxPubkey, r.HeardKey, r.HeardKeyLen, r.RSSI, r.SNR, r.Lat, r.Lon, r.PosAccM, r.RxAt, r.IngestedAt, r.Src)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return n > 0, nil
|
|
}
|