Files
meshcore-analyzer/cmd/server/rx_coverage.go
T
efitenandClaude Opus 5 00ee4f9e23 fix(coverage): attribute node-discover replies to the responder (#2111)
## Problem

A CoreDrive RX companion that runs node-discover gets no coverage on an
upstream instance. Reported today by an operator on v3.13.0: the app
logged 32 discover replies from one repeater in 16 minutes (`heard
751a49f0c5dadc70 (8B, discover)`), all published, and
`client_receptions` stayed at 0 rows for that companion while
`client_rx_observations` held 93.

Two gaps, both on the upstream side:

1. **Ingestor.** `deriveHeardKey` attributes a FLOOD `path[last]` and a
0-hop advert, and drops a 0-hop `CONTROL/DISCOVER_RESP` (firmware
`CTL_TYPE_NODE_DISCOVER_RESP`). The reply carries the responder's own
pubkey at offset 6 of the payload, which `decoder.go` already parses
into `CtrlPubKey` (#1802), but the coverage path never used it.
2. **Server.** CoreDrive RX asks for `DISCOVER_PREFIX_ONLY`, so the
firmware answers with an 8-byte prefix
(`simple_repeater/MyMesh.cpp:799-805`). `coverageHeardKeyCandidates`
only built the 64, 6 and 4 hex candidates, so a 16-hex `heard_key` would
be stored and then matched by no per-node coverage query.

## Change

- `cmd/ingestor/client_reception.go`: a third branch in `deriveHeardKey`
for a discover response with no hops. Accepts exactly 8 or 32 bytes,
nothing truncated, stored with `src='discover'`.
- `cmd/server/rx_coverage.go`: adds the 16-hex prefix to
`coverageHeardKeyCandidates`.
- `docs/client-rx-coverage.md`: documents the `discover` source, the
8-byte keylen and the four-candidate lookup.

The leaderboard and `/api/rx-coverage` read `client_receptions` without
a key filter, so they pick the rows up without a change. Name resolution
goes through `batchResolveHeardKeys`, which is a prefix lookup and
handles 16 hex as is.

## Evidence from a deployment that has had this since 2026-08-19

On analyzer.on8ar.eu, `client_receptions` over the last 7 days by `src`:
discover 4232, rxlog 4909, geo 548, advert 34. Discover replies are 44%
of all coverage rows there (4232 of 9723); on an upstream instance those
rows are not written. They cannot be backfilled afterwards either:
`client_rx_observations` keeps no raw bytes, so the responder pubkey is
gone.

## Tests

- `TestDeriveHeardKey` and `TestBuildClientReception` gain discover
cases: 8-byte and 32-byte keys accepted (32-byte uppercase input
lowercased), a 3-byte and an empty key rejected, a non-discover CONTROL
rejected, a discover response with hops not attributed.
- `TestHandleClientPacketDiscoverRespWritesReception`: end to end, a raw
0-hop DISCOVER_RESP on the client topic writes one `client_receptions`
row with `src='discover'`.
- `TestCoverageHeardKeyCandidatesIncludesDiscoverPrefix`: the 16-hex
prefix is among the per-node candidates.
- Ran locally on Windows: `go test ./...` in `cmd/server` passes; in
`cmd/ingestor` everything passes except
`TestWriteStatsAtomic_SymlinkAtDestIsReplaced`, which needs the symlink
privilege on Windows and fails on clean master too.

Not done: no browser validation, as the change is ingestor and server
only and the frontend reads the same endpoints. Not included: geographic
resolution of 1-byte hops (`src='geo'`) and the RF noise layer, which
are separate changes.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 17:15:50 +02:00

379 lines
14 KiB
Go

package main
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"github.com/gorilla/mux"
)
// coverageRow is one raw reception read from client_receptions.
type coverageRow struct {
Lat, Lon float64
SNR *float64
RSSI *int
HeardKey string // directly-heard node key (2-3 byte prefix or full pubkey), lowercase
RxAt string // reception time (RFC3339); used to pick the latest SNR per node
}
// coverageFeatureCap bounds the number of hex cells returned in one response.
// A wide bbox at high zoom over the 30-day window could otherwise emit multi-MB
// GeoJSON; when more cells exist the densest are kept and Truncated is set (#12).
const coverageFeatureCap = 5000
// coverageCellNodeCap bounds the per-cell node breakdown shipped on the wire
// (the client only renders the top ~10). NodesTruncated flags that more were
// heard than returned (#11).
const coverageCellNodeCap = 25
// GeoJSON output (named structs, no map[string]interface{} — AGENTS.md).
// Truncated is a non-standard foreign member (ignored by GeoJSON consumers like
// Leaflet) that signals the cell list was capped at coverageFeatureCap.
type CoverageFeatureCollection struct {
Type string `json:"type"` // "FeatureCollection"
Features []CoverageFeature `json:"features"`
Truncated bool `json:"truncated,omitempty"`
// Per-node summary (set only by the per-node endpoint): total mobile-client
// receptions of this node and how many distinct companions heard it. Foreign
// members, omitempty so the global endpoint's payload is unchanged (#3).
MobileReceptions int `json:"mobile_receptions,omitempty"`
MobileClients int `json:"mobile_clients,omitempty"`
}
type CoverageFeature struct {
Type string `json:"type"` // "Feature"
Geometry CoveragePolygon `json:"geometry"`
Properties CoverageProperties `json:"properties"`
}
type CoveragePolygon struct {
Type string `json:"type"` // "Polygon"
Coordinates [][][2]float64 `json:"coordinates"` // one ring: [ [ [lon,lat], ... ] ]
}
type CoverageProperties struct {
Cell string `json:"cell"`
Count int `json:"count"`
BestSNR *float64 `json:"best_snr"`
HasSig bool `json:"has_sig"` // false → render grey (no signal metric)
Nodes []CoverageNode `json:"nodes"` // per-node breakdown, strongest latest-SNR first
NodesTruncated bool `json:"nodes_truncated,omitempty"` // true → more nodes heard than returned (#11)
}
// CoverageNode is one directly-heard node within a cell, with its latest SNR.
type CoverageNode struct {
Prefix string `json:"prefix"` // heard_key (resolved to Name when unique)
Name string `json:"name,omitempty"` // node name, empty if unknown/ambiguous prefix
SNR *float64 `json:"snr"` // latest SNR (by rx_at); nil → heard without signal
Count int `json:"count"`
}
type covAgg struct {
count int
bestSNR *float64
hasSig bool
nodes map[string]*covNodeAgg
}
// covNodeAgg tracks, per directly-heard node within a cell, its reception count and
// the SNR of its most recent reception (by rx_at). name/prefix are the resolved node
// name (when known) and a display prefix fallback. nameKeyLen records the heard_key
// length that set the current name, so the chosen identity is the most specific one
// regardless of row order (#20).
type covNodeAgg struct {
count int
latestAt string
latestSNR *float64
name string
nameKeyLen int
prefix string
}
// nodeResolver maps a heard_key (2-3 byte prefix or full pubkey) to a canonical
// identity key and a display name. A unique match returns (pubkey, name) so the same
// node heard under different prefix lengths collapses into one bucket; unknown or
// ambiguous keys return (heardKey, "") and stay distinct. nil disables resolution.
type nodeResolver func(heardKey string) (key, name string)
// aggregateCoverage bins raw rows into display-resolution hex cells, keeping the
// best (max) SNR per cell, and emits GeoJSON polygons. resolve (may be nil) collapses
// per-node receptions by resolved node identity.
func aggregateCoverage(rows []coverageRow, res int, resolve nodeResolver) CoverageFeatureCollection {
byCell := map[string]*covAgg{}
for _, row := range rows {
cell := hexCellAt(row.Lat, row.Lon, res)
a := byCell[cell]
if a == nil {
a = &covAgg{}
byCell[cell] = a
}
a.count++
if row.SNR != nil {
a.hasSig = true
if a.bestSNR == nil || *row.SNR > *a.bestSNR {
v := *row.SNR
a.bestSNR = &v
}
}
if row.HeardKey != "" {
if a.nodes == nil {
a.nodes = map[string]*covNodeAgg{}
}
key, name := row.HeardKey, ""
if resolve != nil {
if k, n := resolve(row.HeardKey); k != "" {
key, name = k, n
}
}
na := a.nodes[key]
if na == nil {
na = &covNodeAgg{prefix: row.HeardKey}
a.nodes[key] = na
}
// Lock the display identity to the MOST SPECIFIC (longest) heard_key
// that resolved to a non-empty name, tie-broken lexicographically, so
// the name no longer flaps with row/map order (#20). A full-pubkey
// reception thus outranks a short-prefix one for the same node.
if name != "" && (na.name == "" || len(row.HeardKey) > na.nameKeyLen ||
(len(row.HeardKey) == na.nameKeyLen && name < na.name)) {
na.name = name
na.nameKeyLen = len(row.HeardKey)
}
// Display-prefix fallback (shown when name is empty): same precedence so
// it is also order-independent.
if len(row.HeardKey) > len(na.prefix) ||
(len(row.HeardKey) == len(na.prefix) && row.HeardKey < na.prefix) {
na.prefix = row.HeardKey
}
na.count++
// rx_at is RFC3339, so lexical >= is chronological; keep the latest
// SNR. The first row always wins (latestAt starts "", and any value
// >= ""), so no separate count==1 guard is needed.
if row.RxAt >= na.latestAt {
na.latestAt = row.RxAt
na.latestSNR = row.SNR
}
}
}
fc := CoverageFeatureCollection{Type: "FeatureCollection", Features: []CoverageFeature{}}
for cell, a := range byCell {
ring := hexBoundary(cell)
if ring == nil {
continue
}
nodes, nodesTrunc := sortedCoverageNodes(a.nodes)
fc.Features = append(fc.Features, CoverageFeature{
Type: "Feature",
Geometry: CoveragePolygon{Type: "Polygon", Coordinates: [][][2]float64{ring}},
Properties: CoverageProperties{
Cell: cell, Count: a.count, BestSNR: a.bestSNR, HasSig: a.hasSig,
Nodes: nodes, NodesTruncated: nodesTrunc,
},
})
}
// Bound the response: when more cells exist than coverageFeatureCap, keep the
// densest (highest count) and flag the truncation, so a wide/zoomed-out query
// can't emit unbounded multi-MB GeoJSON (#12).
if len(fc.Features) > coverageFeatureCap {
sort.Slice(fc.Features, func(i, j int) bool {
ci, cj := fc.Features[i].Properties.Count, fc.Features[j].Properties.Count
if ci != cj {
return ci > cj // densest first
}
return fc.Features[i].Properties.Cell < fc.Features[j].Properties.Cell // deterministic tie-break
})
fc.Features = fc.Features[:coverageFeatureCap]
fc.Truncated = true
}
// Map iteration is randomized, so sort features by cell for a deterministic
// payload — stable ETag/caching and a non-flaky "first feature" in e2e (#8).
sort.Slice(fc.Features, func(i, j int) bool {
return fc.Features[i].Properties.Cell < fc.Features[j].Properties.Cell
})
return fc
}
// sortedCoverageNodes flattens the per-node aggregates into a slice sorted by latest
// SNR descending (nodes heard without a signal sort last), tie-broken by count then
// prefix for a stable order. The slice is capped at coverageCellNodeCap; truncated
// reports whether more nodes were heard in the cell than returned (#11).
func sortedCoverageNodes(m map[string]*covNodeAgg) (nodes []CoverageNode, truncated bool) {
out := make([]CoverageNode, 0, len(m))
for _, na := range m {
out = append(out, CoverageNode{Prefix: na.prefix, Name: na.name, SNR: na.latestSNR, Count: na.count})
}
sort.Slice(out, func(i, j int) bool {
si, sj := out[i].SNR, out[j].SNR
if (si == nil) != (sj == nil) {
return si != nil // signal before no-signal
}
if si != nil && *si != *sj {
return *si > *sj
}
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Prefix < out[j].Prefix
})
if len(out) > coverageCellNodeCap {
return out[:coverageCellNodeCap], true
}
return out, false
}
type bbox struct{ MinLat, MinLon, MaxLat, MaxLon float64 }
// coverageHeardKeyCandidates returns the exact heard_key values that identify a
// node: its full pubkey (stored with heard_keylen 32), the 8-byte (16 hex) prefix
// a node-discover response carries, and the 2-byte (4 hex) and 3-byte (6 hex)
// prefixes a relay logs.
//
// The 16-hex entry is load-bearing, not defensive: corescope-rx asks for
// DISCOVER_PREFIX_ONLY (src/app.js sends CTRL_NODE_DISCOVER_REQ|0x01 to save 24
// bytes of airtime per reply), and the firmware then answers with 6+8 bytes
// (simple_repeater/MyMesh.cpp:799-805). So essentially EVERY discover-attributed
// reception is an 8-byte key; without this candidate they are stored and then
// never matched by any per-node coverage query. Matching heard_key IN (these) is
// equivalent to the old "heard_keylen=32 AND heard_key=? OR heard_keylen IN (2,3)
// AND substr(?,1,keylen*2)=heard_key", but sargable — so the (heard_key, …)
// composite index seeks the few matching rows instead of scanning the bbox (#5).
func coverageHeardKeyCandidates(pubkey string) []string {
pk := strings.ToLower(pubkey)
seen := map[string]bool{}
out := make([]string, 0, 4)
for _, c := range []string{pk, prefixOrEmpty(pk, 16), prefixOrEmpty(pk, 6), prefixOrEmpty(pk, 4)} {
if c != "" && !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out
}
func prefixOrEmpty(s string, n int) string {
if len(s) >= n {
return s[:n]
}
return ""
}
// sqlPlaceholders returns "?,?,…" with n placeholders (n >= 1).
func sqlPlaceholders(n int) string {
if n <= 1 {
return "?"
}
return strings.Repeat("?,", n-1) + "?"
}
// queryCoverageRows returns raw coverage rows where the directly-heard node
// matches the target pubkey by its 2-3 byte prefix (or full pubkey), within the
// bbox. Read-only (server RO connection).
func (s *Server) queryCoverageRows(pubkey string, b bbox) ([]coverageRow, error) {
cands := coverageHeardKeyCandidates(pubkey)
args := make([]interface{}, 0, len(cands)+4)
for _, c := range cands {
args = append(args, c)
}
args = append(args, b.MinLat, b.MaxLat, b.MinLon, b.MaxLon)
rows, err := s.db.conn.Query(`
SELECT lat, lon, snr, rssi, heard_key, rx_at
FROM client_receptions
WHERE heard_key IN (`+sqlPlaceholders(len(cands))+`)
AND lat BETWEEN ? AND ? AND lon BETWEEN ? AND ?`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCoverageRows(rows)
}
// mobileRxStats returns the total mobile-client receptions of a node (by its
// 2-3 byte prefix or full pubkey) and the number of distinct contributing clients.
func (s *Server) mobileRxStats(pubkey string) (count, clients int) {
if s.db == nil || s.db.conn == nil {
return 0, 0
}
cands := coverageHeardKeyCandidates(pubkey)
args := make([]interface{}, len(cands))
for i, c := range cands {
args[i] = c
}
s.db.conn.QueryRow(`
SELECT COUNT(*), COUNT(DISTINCT rx_pubkey) FROM client_receptions
WHERE heard_key IN (`+sqlPlaceholders(len(cands))+`)`, args...).Scan(&count, &clients)
return count, clients
}
// zoomToHexRes maps a Leaflet zoom level to the display resolution used for hex
// binning. Resolution == zoom (clamped to a sane range) so hex size tracks the map
// scale 1:1 and renders at a constant ~hexTargetPx (see hexSizeForRes). The clamp also
// guards the missing-param case (z parses to 0).
func zoomToHexRes(z int) int {
switch {
case z < 3:
return 3
case z > 18:
return 18
default:
return z
}
}
func parseBBox(s string) (bbox, bool) {
p := strings.Split(s, ",")
if len(p) != 4 {
return bbox{}, false
}
v := make([]float64, 4)
for i := range p {
f, err := strconv.ParseFloat(strings.TrimSpace(p[i]), 64)
if err != nil {
return bbox{}, false
}
v[i] = f
}
return bbox{MinLat: v[0], MinLon: v[1], MaxLat: v[2], MaxLon: v[3]}, true
}
// handleNodeRxCoverage serves per-node mobile RX coverage as a GeoJSON hex grid.
func (s *Server) handleNodeRxCoverage(w http.ResponseWriter, r *http.Request) {
if !s.requireClientRxCoverage(w, r) {
return
}
pubkey := strings.ToLower(mux.Vars(r)["pubkey"])
// Mirror handleNodeReach's gate at this same {pubkey}: reject malformed keys,
// and 404 blacklisted / hidden-prefix nodes. Hiding only the node *name* (via
// heardKeyResolver) still leaked the GPS hex bins and mobile_receptions /
// mobile_clients counts for a node the rest of the API hides (#1727 r2).
if !isHexPubkey(pubkey) {
http.Error(w, "invalid pubkey: expected 64 hex chars", http.StatusBadRequest)
return
}
if (s.cfg != nil && s.cfg.IsBlacklisted(pubkey)) || s.isPubkeyHidden(pubkey) {
http.NotFound(w, r)
return
}
b, ok := parseBBox(r.URL.Query().Get("bbox"))
if !ok {
http.Error(w, "bbox required as minLat,minLon,maxLat,maxLon", http.StatusBadRequest)
return
}
if s.db == nil || s.db.conn == nil {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
return
}
z, _ := strconv.Atoi(r.URL.Query().Get("z"))
rows, err := s.queryCoverageRows(pubkey, b)
if err != nil {
http.Error(w, "query failed", http.StatusInternalServerError)
return
}
fc := aggregateCoverage(rows, zoomToHexRes(z), s.heardKeyResolverFor(rows))
// Attach the node-wide reception/contributor totals (#3): the bbox limits the
// hex features to the current view, but these summarise all of this node's
// mobile coverage so the UI can show "heard by N clients" regardless of pan.
fc.MobileReceptions, fc.MobileClients = s.mobileRxStats(pubkey)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(fc)
}