mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-10 19:57:32 +00:00
## 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>
272 lines
11 KiB
Go
272 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"testing"
|
|
)
|
|
|
|
// TestAggregateCoverageCapsNodesPerCell verifies #11: a cell that heard more than
|
|
// coverageCellNodeCap distinct nodes ships at most that many, with NodesTruncated set.
|
|
func TestAggregateCoverageCapsNodesPerCell(t *testing.T) {
|
|
rows := make([]coverageRow, 0, coverageCellNodeCap+5)
|
|
for i := 0; i < coverageCellNodeCap+5; i++ {
|
|
rows = append(rows, coverageRow{
|
|
Lat: 51.05, Lon: 3.72, SNR: covF(float64(-i)),
|
|
HeardKey: fmt.Sprintf("aa%06x", i), RxAt: "2026-06-01T10:00:00Z",
|
|
})
|
|
}
|
|
fc := aggregateCoverage(rows, 9, nil)
|
|
if len(fc.Features) != 1 {
|
|
t.Fatalf("expected 1 cell, got %d", len(fc.Features))
|
|
}
|
|
p := fc.Features[0].Properties
|
|
if len(p.Nodes) != coverageCellNodeCap || !p.NodesTruncated {
|
|
t.Fatalf("want %d nodes + truncated, got %d nodes truncated=%v", coverageCellNodeCap, len(p.Nodes), p.NodesTruncated)
|
|
}
|
|
}
|
|
|
|
// TestAggregateCoverageCapsFeatures verifies #12: a query spanning more than
|
|
// coverageFeatureCap cells is bounded to that many features with Truncated set,
|
|
// and a smaller query is not truncated.
|
|
func TestAggregateCoverageCapsFeatures(t *testing.T) {
|
|
// 0.1° spacing >> a res-9 cell (~4 km), so each point lands in its own cell.
|
|
rows := make([]coverageRow, 0, coverageFeatureCap+200)
|
|
side := 75 // 75*75 = 5625 > 5000
|
|
for i := 0; i < side*side; i++ {
|
|
lat := 10.0 + float64(i/side)*0.1
|
|
lon := 10.0 + float64(i%side)*0.1
|
|
rows = append(rows, coverageRow{Lat: lat, Lon: lon, SNR: covF(-5)})
|
|
}
|
|
fc := aggregateCoverage(rows, 9, nil)
|
|
if len(fc.Features) != coverageFeatureCap || !fc.Truncated {
|
|
t.Fatalf("want %d features + truncated, got %d truncated=%v", coverageFeatureCap, len(fc.Features), fc.Truncated)
|
|
}
|
|
// Still sorted by cell after truncation.
|
|
for i := 1; i < len(fc.Features); i++ {
|
|
if fc.Features[i-1].Properties.Cell > fc.Features[i].Properties.Cell {
|
|
t.Fatalf("truncated features not sorted by cell at %d", i)
|
|
}
|
|
}
|
|
// A small query is not truncated.
|
|
small := aggregateCoverage(rows[:10], 9, nil)
|
|
if small.Truncated {
|
|
t.Fatalf("small query should not be truncated")
|
|
}
|
|
}
|
|
|
|
func covF(f float64) *float64 { return &f }
|
|
|
|
func TestAggregateCoverageBucketsBestSNR(t *testing.T) {
|
|
rows := []coverageRow{
|
|
{Lat: 51.05000, Lon: 3.72000, SNR: covF(-12)},
|
|
{Lat: 51.05001, Lon: 3.72001, SNR: covF(-6)}, // same cell, stronger
|
|
}
|
|
fc := aggregateCoverage(rows, 9, nil)
|
|
if len(fc.Features) != 1 {
|
|
t.Fatalf("expected 1 cell, got %d", len(fc.Features))
|
|
}
|
|
if p := fc.Features[0].Properties; p.BestSNR == nil || *p.BestSNR != -6 || p.Count != 2 || !p.HasSig {
|
|
t.Fatalf("bad props: %+v", fc.Features[0].Properties)
|
|
}
|
|
if g := fc.Features[0].Geometry; g.Type != "Polygon" || len(g.Coordinates) != 1 {
|
|
t.Fatalf("bad geometry: %+v", g)
|
|
}
|
|
if _, err := json.Marshal(fc); err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAggregateCoverageGreyWhenNoSignal(t *testing.T) {
|
|
fc := aggregateCoverage([]coverageRow{{Lat: 51.05, Lon: 3.72}}, 9, nil)
|
|
if len(fc.Features) != 1 || fc.Features[0].Properties.HasSig {
|
|
t.Fatalf("expected one grey (no-sig) cell, got %+v", fc.Features)
|
|
}
|
|
}
|
|
|
|
// TestAggregateCoverageNodeBreakdown covers the per-cell node list: each heard node
|
|
// keeps its latest SNR (by rx_at) and reception count, sorted strongest-first with
|
|
// heard-without-signal nodes last.
|
|
func TestAggregateCoverageNodeBreakdown(t *testing.T) {
|
|
rows := []coverageRow{
|
|
// node A: two receptions; the later one (t2) has the weaker SNR -10.
|
|
{Lat: 51.05, Lon: 3.72, SNR: covF(-4), HeardKey: "aabb", RxAt: "2026-06-01T10:00:00Z"},
|
|
{Lat: 51.05001, Lon: 3.72001, SNR: covF(-10), HeardKey: "aabb", RxAt: "2026-06-02T10:00:00Z"},
|
|
// node B: single reception, strongest latest SNR.
|
|
{Lat: 51.05, Lon: 3.72, SNR: covF(-6), HeardKey: "ccdd", RxAt: "2026-06-01T10:00:00Z"},
|
|
// node C: heard without a signal metric.
|
|
{Lat: 51.05, Lon: 3.72, HeardKey: "eeff", RxAt: "2026-06-01T10:00:00Z"},
|
|
}
|
|
fc := aggregateCoverage(rows, 9, nil)
|
|
if len(fc.Features) != 1 {
|
|
t.Fatalf("expected 1 cell, got %d", len(fc.Features))
|
|
}
|
|
nodes := fc.Features[0].Properties.Nodes
|
|
if len(nodes) != 3 {
|
|
t.Fatalf("expected 3 nodes, got %d (%+v)", len(nodes), nodes)
|
|
}
|
|
if nodes[0].Prefix != "ccdd" || nodes[0].SNR == nil || *nodes[0].SNR != -6 {
|
|
t.Errorf("node[0] want ccdd@-6 (strongest), got %+v", nodes[0])
|
|
}
|
|
if nodes[1].Prefix != "aabb" || nodes[1].SNR == nil || *nodes[1].SNR != -10 || nodes[1].Count != 2 {
|
|
t.Errorf("node[1] want aabb latest -10 count 2, got %+v", nodes[1])
|
|
}
|
|
if nodes[2].Prefix != "eeff" || nodes[2].SNR != nil {
|
|
t.Errorf("node[2] want eeff no-signal (last), got %+v", nodes[2])
|
|
}
|
|
}
|
|
|
|
// TestResolveHeardKey covers heard_key → (pubkey, name) resolution: a unique match
|
|
// returns the canonical pubkey + name; an ambiguous prefix (>1 node) and an
|
|
// unknown/empty key return the key itself with an empty name.
|
|
func TestResolveHeardKey(t *testing.T) {
|
|
db := seedCoverageDB(t)
|
|
mustExecDB(t, db, `INSERT INTO nodes (public_key,name,role) VALUES ('aabbccdd11223344','Alice','repeater')`)
|
|
mustExecDB(t, db, `INSERT INTO nodes (public_key,name,role) VALUES ('aabbcc99887766aa','Bob','repeater')`)
|
|
srv := &Server{db: db}
|
|
if k, n := srv.resolveHeardKey("aabbccdd"); k != "aabbccdd11223344" || n != "Alice" {
|
|
t.Errorf("unique prefix → (pubkey,Alice), got (%q,%q)", k, n)
|
|
}
|
|
if k, n := srv.resolveHeardKey("aabbcc"); k != "aabbcc" || n != "" {
|
|
t.Errorf("ambiguous prefix → (key,\"\"), got (%q,%q)", k, n)
|
|
}
|
|
if k, n := srv.resolveHeardKey("ffff"); k != "ffff" || n != "" {
|
|
t.Errorf("unknown prefix → (key,\"\"), got (%q,%q)", k, n)
|
|
}
|
|
if k, n := srv.resolveHeardKey(""); k != "" || n != "" {
|
|
t.Errorf("empty prefix → (\"\",\"\"), got (%q,%q)", k, n)
|
|
}
|
|
}
|
|
|
|
// TestAggregateCoverageMergesResolvedNodes verifies that the same node heard under
|
|
// two different heard_keys (e.g. a 3-byte prefix and the full pubkey) collapses into a
|
|
// single entry — summed count, latest SNR — when the resolver maps both to one node.
|
|
func TestAggregateCoverageMergesResolvedNodes(t *testing.T) {
|
|
rows := []coverageRow{
|
|
{Lat: 51.05, Lon: 3.72, SNR: covF(-4), HeardKey: "aabbcc", RxAt: "2026-06-01T10:00:00Z"},
|
|
{Lat: 51.05, Lon: 3.72, SNR: covF(-9), HeardKey: "aabbccdd11223344", RxAt: "2026-06-03T10:00:00Z"},
|
|
{Lat: 51.05, Lon: 3.72, SNR: covF(-7), HeardKey: "aabbcc", RxAt: "2026-06-02T10:00:00Z"},
|
|
}
|
|
resolve := func(hk string) (string, string) { return "aabbccdd11223344", "Alice" }
|
|
fc := aggregateCoverage(rows, 9, resolve)
|
|
if len(fc.Features) != 1 {
|
|
t.Fatalf("expected 1 cell, got %d", len(fc.Features))
|
|
}
|
|
nodes := fc.Features[0].Properties.Nodes
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("expected 1 merged node, got %d (%+v)", len(nodes), nodes)
|
|
}
|
|
n := nodes[0]
|
|
if n.Name != "Alice" || n.Count != 3 || n.SNR == nil || *n.SNR != -9 {
|
|
t.Errorf("merged node want Alice count 3 latest -9, got %+v (snr=%v)", n, n.SNR)
|
|
}
|
|
}
|
|
|
|
// TestAggregateCoverageDeterministicFeatureOrder verifies #8: features come out
|
|
// sorted by cell regardless of Go's randomized map iteration, so the GeoJSON is
|
|
// stable (cacheable / non-flaky e2e).
|
|
func TestAggregateCoverageDeterministicFeatureOrder(t *testing.T) {
|
|
rows := []coverageRow{
|
|
{Lat: 51.0, Lon: 3.0, SNR: covF(-5)},
|
|
{Lat: 48.0, Lon: 2.0, SNR: covF(-5)},
|
|
{Lat: 52.0, Lon: 4.0, SNR: covF(-5)},
|
|
{Lat: 40.0, Lon: -3.0, SNR: covF(-5)},
|
|
}
|
|
fc := aggregateCoverage(rows, 9, nil)
|
|
if len(fc.Features) < 2 {
|
|
t.Fatalf("expected multiple cells, got %d", len(fc.Features))
|
|
}
|
|
for i := 1; i < len(fc.Features); i++ {
|
|
if fc.Features[i-1].Properties.Cell > fc.Features[i].Properties.Cell {
|
|
t.Fatalf("features not sorted by cell at %d: %q > %q", i,
|
|
fc.Features[i-1].Properties.Cell, fc.Features[i].Properties.Cell)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAggregateCoverageNamePrecedenceOrderIndependent verifies #20: when two
|
|
// heard_keys resolve to the same node but the resolver returns different display
|
|
// names, the most specific (longest) heard_key wins regardless of row order, so
|
|
// the name no longer depends on map/row iteration.
|
|
func TestAggregateCoverageNamePrecedenceOrderIndependent(t *testing.T) {
|
|
resolve := func(hk string) (string, string) {
|
|
if hk == "aabbccdd11223344" {
|
|
return "aabbccdd11223344", "Alice"
|
|
}
|
|
return "aabbccdd11223344", "AliceShortPrefix"
|
|
}
|
|
full := coverageRow{Lat: 51.05, Lon: 3.72, SNR: covF(-5), HeardKey: "aabbccdd11223344", RxAt: "2026-06-01T10:00:00Z"}
|
|
prefix := coverageRow{Lat: 51.05, Lon: 3.72, SNR: covF(-6), HeardKey: "aabbcc", RxAt: "2026-06-02T10:00:00Z"}
|
|
|
|
for _, order := range [][]coverageRow{{full, prefix}, {prefix, full}} {
|
|
fc := aggregateCoverage(order, 9, resolve)
|
|
nodes := fc.Features[0].Properties.Nodes
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("expected 1 merged node, got %d (%+v)", len(nodes), nodes)
|
|
}
|
|
if nodes[0].Name != "Alice" {
|
|
t.Fatalf("name precedence flapped with row order: got %q, want Alice", nodes[0].Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZoomToHexRes(t *testing.T) {
|
|
// Resolution tracks zoom 1:1 within [3,18], clamped at the edges (z=0 is the
|
|
// missing-param case).
|
|
cases := map[int]int{0: 3, 3: 3, 8: 8, 16: 16, 18: 18, 25: 18}
|
|
for z, want := range cases {
|
|
if got := zoomToHexRes(z); got != want {
|
|
t.Fatalf("zoomToHexRes(%d)=%d, want %d", z, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHexSizeRendersConstantPx verifies the core fix: a hex sized for resolution
|
|
// res renders at a constant ~hexTargetPx on screen at the corresponding zoom level,
|
|
// instead of the old fixed-meter buckets that were ~2px when zoomed out.
|
|
func TestHexSizeRendersConstantPx(t *testing.T) {
|
|
for res := 4; res <= 16; res++ {
|
|
// On-screen point-to-point height = 2*circumradius / mercUnitsPerPixel(zoom),
|
|
// where mercUnitsPerPixel = mercUPPZ0 / 2^zoom and zoom == res.
|
|
px := 2 * hexSizeForRes(res) * math.Pow(2, float64(res)) / mercUPPZ0
|
|
if math.Abs(px-hexTargetPx) > 0.001 {
|
|
t.Fatalf("res %d renders %.2fpx, want %.2fpx", res, px, hexTargetPx)
|
|
}
|
|
// Size must halve each zoom step (finer grid as you zoom in).
|
|
if ratio := hexSizeForRes(res) / hexSizeForRes(res+1); math.Abs(ratio-2) > 1e-9 {
|
|
t.Fatalf("res %d→%d size ratio %.4f, want 2", res, res+1, ratio)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCoverageHeardKeyCandidatesIncludesDiscoverPrefix pins the 8-byte (16 hex)
|
|
// candidate. corescope-rx requests DISCOVER_PREFIX_ONLY, so the firmware answers
|
|
// with a 6+8 byte body and essentially every discover-attributed reception is
|
|
// stored under an 8-byte heard_key. Without this candidate those rows exist in
|
|
// client_receptions and are matched by no per-node coverage query — stored, and
|
|
// invisible on the node page.
|
|
func TestCoverageHeardKeyCandidatesIncludesDiscoverPrefix(t *testing.T) {
|
|
pk := "efef7943505052b47f1809488ea4b4d3942d4ed72d2b1953b90a9f5e62a65fb5"
|
|
got := coverageHeardKeyCandidates(pk)
|
|
|
|
want := map[string]bool{
|
|
pk: false,
|
|
"efef7943505052b4": false, // 8-byte discover prefix
|
|
"efef79": false, // 3-byte relay hop
|
|
"efef": false, // 2-byte relay hop
|
|
}
|
|
for _, c := range got {
|
|
if _, ok := want[c]; !ok {
|
|
t.Errorf("unexpected candidate %q", c)
|
|
continue
|
|
}
|
|
want[c] = true
|
|
}
|
|
for c, seen := range want {
|
|
if !seen {
|
|
t.Errorf("candidate %q missing from %v", c, got)
|
|
}
|
|
}
|
|
}
|