diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 301abfd7d..1672066da 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -584,6 +584,7 @@ jobs: suite 1 test-channel-issue-1111-e2e.js BASE_URL=$B suite 2 test-map-modal-fluid-e2e.js BASE_URL=$B suite 2 test-map-nodes-pagination-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B + suite 2 test-map-estimated-e2e.js CHROMIUM_REQUIRE=1 BASE_URL=$B suite 3 test-observer-iata-1188-e2e.js BASE_URL=$B suite 2 test-issue-1639-observers-sort-e2e.js BASE_URL=$B suite 2 test-issue-661-airtime-e2e.js BASE_URL=$B diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 38c7286e8..3ae41e277 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -141,6 +141,7 @@ func routeDescriptions() map[string]routeMeta { {Name: "status", Description: "Filter by status (active/stale/offline)", Type: "string"}, }}, "GET /api/nodes/search": {Summary: "Search nodes", Description: "Search nodes by name or public key prefix.", Tag: "nodes", QueryParams: []paramMeta{{Name: "q", Description: "Search query", Type: "string", Required: true}}}, + "GET /api/nodes/reported": {Summary: "Repeaters known only from their reported name", Description: "Repeaters that told a mobile companion their own name (client_node_names, ANON_REQ_TYPE_OWNER) and have no advert position in nodes. Each carries its reception counts and, when companions heard it directly from at least `method.min_spots` spots at least `method.min_spread_m` apart, an `estimate`: a SNR-weighted centroid of those spots with `radius_m` (never below `method.min_radius_m`). The estimate is where companions stood, weighted, not a measured position. Cached for 5 minutes.", Tag: "nodes"}, "GET /api/nodes/bulk-health": {Summary: "Bulk node health", Description: "Returns health status for all nodes in one call.", Tag: "nodes"}, "GET /api/nodes/network-status": {Summary: "Network status summary", Description: "Returns counts of active, stale, and offline nodes.", Tag: "nodes"}, "GET /api/nodes/{pubkey}": {Summary: "Get node detail", Description: "Returns full detail for a single node by public key. For repeater/room nodes this includes the issue #672 usefulness axes + composite score/grade (see the Node schema).", Tag: "nodes", Response: schemaRef("NodeDetailResponse")}, diff --git a/cmd/server/reported_nodes.go b/cmd/server/reported_nodes.go new file mode 100644 index 000000000..a555f0375 --- /dev/null +++ b/cmd/server/reported_nodes.go @@ -0,0 +1,364 @@ +package main + +import ( + "database/sql" + "log" + "math" + "net/http" + "strings" + "sync" + "time" +) + +// Repeaters known only from client_node_names: a mobile companion asked them +// for their own name (ANON_REQ_TYPE_OWNER) because no advert had ever named +// them, so nodes has no row and no position for them. /api/nodes/reported +// lists every such repeater so the UI can find it, and adds an estimated +// position when the receptions allow one. +// +// The estimate is a SNR-weighted centroid of where companions stood when they +// heard the repeater directly. It is not trilateration: a repeater only ever +// heard from one side lands on that side, which is why the radius is never +// smaller than estimateMinRadiusM and why the UI must present it as an +// estimate, never as a node position. + +const ( + // estimateSpotM merges receptions closer than this into one spot, so a + // companion parked in one place counts once, however long it stays. + estimateSpotM = 100.0 + // estimateMinSpots and estimateMinSpreadM: below this there is no + // estimate, because the centroid of one place is that place, which says + // where the companion was, not where the repeater is. + estimateMinSpots = 3 + estimateMinSpreadM = 200.0 + // estimateMinRadiusM is the smallest uncertainty radius reported. + estimateMinRadiusM = 500.0 + // estimateNoSNRdB stands in for a reception without an SNR value. + estimateNoSNRdB = -20.0 + // reportedNodesTTL bounds how often the list is recomputed. + reportedNodesTTL = 5 * time.Minute + // reportedNodesMax caps the list, so the IN query stays bounded. + reportedNodesMax = 500 +) + +// PositionEstimate is an estimated position with its uncertainty. +type PositionEstimate struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + RadiusM float64 `json:"radius_m"` +} + +// ReportedNode is one repeater known only from its own reported name. +type ReportedNode struct { + Pubkey string `json:"pubkey"` + Name string `json:"name"` + NameReportedAt string `json:"name_reported_at"` + Reporters int `json:"reporters"` + Receptions int `json:"receptions"` + Spots int `json:"spots"` + FirstHeard string `json:"first_heard,omitempty"` + LastHeard string `json:"last_heard,omitempty"` + Estimate *PositionEstimate `json:"estimate,omitempty"` +} + +// ReportedNodesResponse is the /api/nodes/reported reply. Method carries the +// thresholds the estimate used, so the UI can explain a missing estimate. +type ReportedNodesResponse struct { + Nodes []ReportedNode `json:"nodes"` + Method struct { + SpotM float64 `json:"spot_m"` + MinSpots int `json:"min_spots"` + MinSpreadM float64 `json:"min_spread_m"` + MinRadiusM float64 `json:"min_radius_m"` + } `json:"method"` +} + +// rxPoint is one direct reception: where the companion stood, and how well. +type rxPoint struct { + Lat, Lon float64 + SNR *float64 +} + +// distanceM is the great-circle distance in metres. +func distanceM(lat1, lon1, lat2, lon2 float64) float64 { + return haversineKm(lat1, lon1, lat2, lon2) * 1000 +} + +// spotsOf merges receptions within estimateSpotM of a spot's first reception +// into that spot, keeping the best SNR. Greedy in input order, which is fine +// at 100 m: the merge only exists to stop one parked companion counting many +// times. +func spotsOf(pts []rxPoint) []rxPoint { + var spots []rxPoint + for _, p := range pts { + merged := false + for i := range spots { + if distanceM(spots[i].Lat, spots[i].Lon, p.Lat, p.Lon) < estimateSpotM { + if p.SNR != nil && (spots[i].SNR == nil || *p.SNR > *spots[i].SNR) { + spots[i].SNR = p.SNR + } + merged = true + break + } + } + if !merged { + spots = append(spots, p) + } + } + return spots +} + +// estimatePosition returns the estimate and the spot count. The estimate is +// nil below estimateMinSpots spots or when no two spots are +// estimateMinSpreadM apart. +func estimatePosition(pts []rxPoint) (*PositionEstimate, int) { + spots := spotsOf(pts) + if len(spots) < estimateMinSpots { + return nil, len(spots) + } + spread := 0.0 + for i := range spots { + for j := i + 1; j < len(spots); j++ { + spread = math.Max(spread, distanceM(spots[i].Lat, spots[i].Lon, spots[j].Lat, spots[j].Lon)) + } + } + if spread < estimateMinSpreadM { + return nil, len(spots) + } + // Linear amplitude weight: 6 dB better counts twice as much. + var sw, slat, slon float64 + for _, s := range spots { + snr := estimateNoSNRdB + if s.SNR != nil { + snr = *s.SNR + } + w := math.Pow(10, snr/20) + sw += w + slat += w * s.Lat + slon += w * s.Lon + } + est := &PositionEstimate{Lat: slat / sw, Lon: slon / sw, RadiusM: estimateMinRadiusM} + for _, s := range spots { + est.RadiusM = math.Max(est.RadiusM, distanceM(est.Lat, est.Lon, s.Lat, s.Lon)) + } + est.Lat = math.Round(est.Lat*1e5) / 1e5 + est.Lon = math.Round(est.Lon*1e5) / 1e5 + est.RadiusM = math.Round(est.RadiusM) + return est, len(spots) +} + +type reportedNodesCache struct { + mu sync.Mutex + at time.Time + resp *ReportedNodesResponse +} + +func (s *Server) handleReportedNodes(w http.ResponseWriter, r *http.Request) { + if s.db == nil || s.db.conn == nil { + writeError(w, http.StatusServiceUnavailable, "unavailable") + return + } + c := &s.reportedNodes + c.mu.Lock() + defer c.mu.Unlock() + if c.resp == nil || time.Since(c.at) > reportedNodesTTL { + start := time.Now() + resp, err := s.computeReportedNodes() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + log.Printf("[reported-nodes] %d repeaters, %d with an estimate, computed in %s", len(resp.Nodes), countEstimates(resp.Nodes), time.Since(start).Round(time.Millisecond)) + c.resp, c.at = resp, time.Now() + } + writeJSON(w, c.resp) +} + +func countEstimates(nodes []ReportedNode) int { + n := 0 + for _, nd := range nodes { + if nd.Estimate != nil { + n++ + } + } + return n +} + +// tablePresent reports whether a table exists. Used for inactive_nodes and +// client_receptions, which an older or partial database may lack. +func (db *DB) tablePresent(name string) bool { + var n int + err := db.conn.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, name).Scan(&n) + return err == nil && n > 0 +} + +// computeReportedNodes builds the list: every client_node_names target with no +// advert position in nodes (or inactive_nodes), with its direct receptions. +func (s *Server) computeReportedNodes() (*ReportedNodesResponse, error) { + resp := &ReportedNodesResponse{Nodes: []ReportedNode{}} + resp.Method.SpotM = estimateSpotM + resp.Method.MinSpots = estimateMinSpots + resp.Method.MinSpreadM = estimateMinSpreadM + resp.Method.MinRadiusM = estimateMinRadiusM + if !s.db.clientNodeNamesPresent() { + return resp, nil + } + + positioned := `EXISTS (SELECT 1 FROM nodes n WHERE n.public_key = c.target AND n.lat IS NOT NULL AND n.lon IS NOT NULL AND NOT (n.lat = 0 AND n.lon = 0))` + if s.db.tablePresent("inactive_nodes") { + positioned += ` OR EXISTS (SELECT 1 FROM inactive_nodes n WHERE n.public_key = c.target AND n.lat IS NOT NULL AND n.lon IS NOT NULL AND NOT (n.lat = 0 AND n.lon = 0))` + } + // The bare name beside MAX(observed_at) is SQLite's bare-column rule: the + // name of the newest observation (same as lookupPrefixes). + rows, err := s.db.conn.Query(` + SELECT target, name, MAX(observed_at), COUNT(DISTINCT rx_pubkey) + FROM client_node_names c + WHERE NOT (`+positioned+`) + GROUP BY target + ORDER BY MAX(observed_at) DESC + LIMIT ?`, reportedNodesMax) + if err != nil { + return nil, err + } + byPK := map[string]*ReportedNode{} + var order []string + for rows.Next() { + var n ReportedNode + if err := rows.Scan(&n.Pubkey, &n.Name, &n.NameReportedAt, &n.Reporters); err != nil { + rows.Close() + return nil, err + } + if s.cfg.IsBlacklisted(n.Pubkey) || s.cfg.IsNameHidden(n.Name) { + continue + } + nn := n + byPK[n.Pubkey] = &nn + order = append(order, n.Pubkey) + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + + if len(order) > 0 && s.db.tablePresent("client_receptions") { + pts, err := s.directReceptions(order) + if err != nil { + return nil, err + } + for _, pk := range order { + n := byPK[pk] + rx := pts[pk] + n.Receptions = len(rx.points) + n.FirstHeard, n.LastHeard = rx.first, rx.last + n.Estimate, n.Spots = estimatePosition(rx.points) + } + } + for _, pk := range order { + resp.Nodes = append(resp.Nodes, *byPK[pk]) + } + return resp, nil +} + +type targetReceptions struct { + points []rxPoint + first, last string +} + +// directReceptions collects, per target, the receptions that certainly heard +// that repeater directly: +// - discover/advert rows keyed by the full pubkey or its 8-byte prefix, which +// carry the responder's own identity +// - rxlog rows keyed by a 2-3 byte path hash, only when that prefix resolves +// uniquely to this target (lookupPrefixes); otherwise another node with +// the same hash could be the one heard +// +// src='geo' rows are left out: their 1-byte hop was placed geographically, +// not heard as this node. +func (s *Server) directReceptions(targets []string) (map[string]*targetReceptions, error) { + keyTarget := map[string]string{} + var short []string + for _, pk := range targets { + keyTarget[pk] = pk + if len(pk) >= 16 { + keyTarget[pk[:16]] = pk + } + for _, n := range []int{4, 6} { + if len(pk) >= n { + short = append(short, pk[:n]) + } + } + } + wanted := make(map[string]bool, len(targets)) + for _, pk := range targets { + wanted[pk] = true + } + for k, h := range s.lookupPrefixes(short) { + if s.visible(h) && wanted[h.pubkey] { + keyTarget[k] = h.pubkey + } + } + + out := make(map[string]*targetReceptions, len(targets)) + for _, pk := range targets { + out[pk] = &targetReceptions{} + } + keys := make([]string, 0, len(keyTarget)) + for k := range keyTarget { + keys = append(keys, k) + } + const chunk = 400 + for i := 0; i < len(keys); i += chunk { + end := i + chunk + if end > len(keys) { + end = len(keys) + } + batch := keys[i:end] + args := make([]interface{}, len(batch)) + for j, k := range batch { + args[j] = k + } + rows, err := s.db.conn.Query(` + SELECT heard_key, src, lat, lon, snr, rx_at FROM client_receptions + WHERE heard_key IN (`+sqlPlaceholders(len(batch))+`) AND src IN ('discover','advert','rxlog')`, args...) + if err != nil { + return nil, err + } + for rows.Next() { + var hk, src, at string + var lat, lon float64 + var snr sql.NullFloat64 + if err := rows.Scan(&hk, &src, &lat, &lon, &snr, &at); err != nil { + rows.Close() + return nil, err + } + hk = strings.ToLower(hk) + // A short key only counts as an rxlog path hash; a discover or + // advert row always carries 8 or 32 bytes. + if len(hk) < 16 && src != "rxlog" { + continue + } + pk, ok := keyTarget[hk] + if !ok { + continue + } + t := out[pk] + p := rxPoint{Lat: lat, Lon: lon} + if snr.Valid { + v := snr.Float64 + p.SNR = &v + } + t.points = append(t.points, p) + if t.first == "" || at < t.first { + t.first = at + } + if at > t.last { + t.last = at + } + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + } + return out, nil +} diff --git a/cmd/server/reported_nodes_test.go b/cmd/server/reported_nodes_test.go new file mode 100644 index 000000000..5377da5ee --- /dev/null +++ b/cmd/server/reported_nodes_test.go @@ -0,0 +1,221 @@ +package main + +import ( + "encoding/json" + "fmt" + "math" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" +) + +func snrp(v float64) *float64 { return &v } + +// Offsets in metres around a reference point, small enough for a flat-earth +// conversion to be exact to well under a metre. +func offset(dNorthM, dEastM float64) (float64, float64) { + const lat0, lon0 = 51.0, 5.38 + return lat0 + dNorthM/110540, lon0 + dEastM/(111320*math.Cos(lat0*math.Pi/180)) +} + +func pt(dN, dE float64, snr *float64) rxPoint { + lat, lon := offset(dN, dE) + return rxPoint{Lat: lat, Lon: lon, SNR: snr} +} + +func TestEstimatePositionNeedsThreeSpreadSpots(t *testing.T) { + // Fifteen receptions from one driveway are one spot: no estimate. + var parked []rxPoint + for i := 0; i < 15; i++ { + parked = append(parked, pt(float64(i), 0, snrp(-5))) + } + if est, spots := estimatePosition(parked); est != nil || spots != 1 { + t.Fatalf("parked: est=%v spots=%d, want nil and 1", est, spots) + } + // Two spots: still nothing. + if est, spots := estimatePosition([]rxPoint{pt(0, 0, snrp(-5)), pt(0, 1000, snrp(-5))}); est != nil || spots != 2 { + t.Fatalf("two spots: est=%v spots=%d", est, spots) + } + // Three spots, but all within 200 m of each other: nothing. + close3 := []rxPoint{pt(0, 0, snrp(-5)), pt(0, 120, snrp(-5)), pt(120, 0, snrp(-5))} + if est, spots := estimatePosition(close3); est != nil || spots != 3 { + t.Fatalf("tight cluster: est=%v spots=%d", est, spots) + } +} + +func TestEstimatePositionWeightedCentroid(t *testing.T) { + // Equal SNR: the plain centroid of the three spots. + eq := []rxPoint{pt(0, 0, snrp(-10)), pt(0, 900, snrp(-10)), pt(900, 0, snrp(-10))} + est, spots := estimatePosition(eq) + if est == nil || spots != 3 { + t.Fatalf("est=%v spots=%d", est, spots) + } + wantLat, wantLon := offset(300, 300) + if d := distanceM(est.Lat, est.Lon, wantLat, wantLon); d > 2 { + t.Errorf("centroid off by %.1f m", d) + } + // One spot 20 dB stronger pulls the estimate towards it. + strong := []rxPoint{pt(0, 0, snrp(10)), pt(0, 900, snrp(-10)), pt(900, 0, snrp(-10))} + est2, _ := estimatePosition(strong) + lat0, lon0 := offset(0, 0) + if distanceM(est2.Lat, est2.Lon, lat0, lon0) >= distanceM(est.Lat, est.Lon, lat0, lon0) { + t.Errorf("the strong spot did not pull the estimate towards it: %+v vs %+v", est2, est) + } +} + +func TestEstimatePositionRadius(t *testing.T) { + // Spots far apart: the radius reaches the farthest spot. + wide := []rxPoint{pt(0, 0, snrp(-5)), pt(0, 3000, snrp(-5)), pt(3000, 0, snrp(-5))} + est, _ := estimatePosition(wide) + if est == nil { + t.Fatal("no estimate") + } + far := 0.0 + for _, p := range wide { + far = math.Max(far, distanceM(est.Lat, est.Lon, p.Lat, p.Lon)) + } + if math.Abs(est.RadiusM-far) > 1 { + t.Errorf("radius %.0f, want the farthest spot at %.0f", est.RadiusM, far) + } + // Spots close together: the radius never drops below the floor. + near := []rxPoint{pt(0, 0, snrp(-5)), pt(0, 250, snrp(-5)), pt(250, 0, snrp(-5))} + if est, _ := estimatePosition(near); est == nil || est.RadiusM != estimateMinRadiusM { + t.Errorf("near: %+v, want radius %v", est, estimateMinRadiusM) + } +} + +// A spot keeps its best SNR, and a reception without SNR still counts as a spot. +func TestSpotsOfKeepsBestSNR(t *testing.T) { + spots := spotsOf([]rxPoint{pt(0, 0, snrp(-12)), pt(30, 0, snrp(-3)), pt(60, 0, nil), pt(0, 500, nil)}) + if len(spots) != 2 { + t.Fatalf("spots = %d, want 2", len(spots)) + } + if spots[0].SNR == nil || *spots[0].SNR != -3 { + t.Errorf("first spot SNR = %v, want -3", spots[0].SNR) + } + if spots[1].SNR != nil { + t.Errorf("second spot SNR = %v, want nil", spots[1].SNR) + } +} + +// --- endpoint --- + +const ( + repA = "c0cdee59e0db981b00000000000000000000000000000000000000000000000a" // reported, spread receptions + repB = "b1b1220000000000000000000000000000000000000000000000000000000000" // reported, one spot + repC = "c2c2330000000000000000000000000000000000000000000000000000000000" // reported, but has an advert position + repH = "c3c3440000000000000000000000000000000000000000000000000000000000" // hidden name +) + +func insRec(t *testing.T, db *DB, hk, src string, dN, dE, snr float64, at string) { + t.Helper() + lat, lon := offset(dN, dE) + mustExecDB(t, db, fmt.Sprintf(`INSERT INTO client_receptions (rx_pubkey,heard_key,heard_keylen,snr,lat,lon,rx_at,ingested_at,src) + VALUES ('comp','%s',%d,%f,%f,%f,'%s','x','%s')`, hk, len(hk)/2, snr, lat, lon, at, src)) +} + +func seedReported(t *testing.T) *Server { + t.Helper() + db := seedCoverageDB(t) + createClientNodeNames(t, db) + insClientName(t, db, repA, "Self Named", "2026-10-16T10:00:00.000Z") + insClientName(t, db, repB, "Lonely", "2026-10-16T09:00:00.000Z") + insClientName(t, db, repC, "Has Advert", "2026-10-16T08:00:00.000Z") + insClientName(t, db, repH, "π«Secret", "2026-10-16T07:00:00.000Z") + mustExecDB(t, db, `INSERT INTO nodes (public_key, name, role, lat, lon) VALUES ('`+repC+`','Has Advert','repeater',51.0,5.38)`) + + // repA: an 8-byte discover key at three spread spots, plus a 2-byte rxlog + // hash that resolves uniquely to it. + insRec(t, db, repA[:16], "discover", 0, 0, -5, "2026-10-01T10:00:00Z") + insRec(t, db, repA[:16], "discover", 0, 900, -10, "2026-10-02T10:00:00Z") + insRec(t, db, repA, "discover", 900, 0, -10, "2026-10-03T10:00:00Z") + insRec(t, db, repA[:4], "rxlog", 10, 10, -6, "2026-10-04T10:00:00Z") + // A geo row is not a direct reception of this node and must be ignored. + insRec(t, db, repA[:4], "geo", 5000, 5000, -1, "2026-10-05T10:00:00Z") + // repB: heard from one place only. + insRec(t, db, repB[:16], "discover", 0, 0, -4, "2026-10-06T10:00:00Z") + insRec(t, db, repB[:16], "discover", 20, 0, -6, "2026-10-06T10:01:00Z") + return &Server{db: db, cfg: &Config{HiddenNamePrefixes: []string{"π«"}}} +} + +func getReported(t *testing.T, srv *Server) ReportedNodesResponse { + t.Helper() + router := mux.NewRouter() + router.HandleFunc("/api/nodes/reported", srv.handleReportedNodes).Methods("GET") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/nodes/reported", nil)) + if rr.Code != 200 { + t.Fatalf("status %d: %s", rr.Code, rr.Body.String()) + } + var resp ReportedNodesResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + return resp +} + +func TestReportedNodesEndpoint(t *testing.T) { + resp := getReported(t, seedReported(t)) + if resp.Method.MinSpots != estimateMinSpots || resp.Method.MinRadiusM != estimateMinRadiusM { + t.Errorf("method = %+v", resp.Method) + } + got := map[string]ReportedNode{} + for _, n := range resp.Nodes { + got[n.Pubkey] = n + } + if len(got) != 2 { + t.Fatalf("nodes = %v, want repA and repB only (repC has an advert position, repH is hidden)", resp.Nodes) + } + a := got[repA] + if a.Name != "Self Named" || a.Receptions != 4 || a.Spots != 3 || a.Estimate == nil { + t.Fatalf("repA = %+v", a) + } + if a.FirstHeard != "2026-10-01T10:00:00Z" || a.LastHeard != "2026-10-04T10:00:00Z" { + t.Errorf("repA heard %s..%s", a.FirstHeard, a.LastHeard) + } + far, _ := offset(5000, 5000) + if a.Estimate.Lat > far-0.01 { + t.Errorf("the geo row moved the estimate: %+v", a.Estimate) + } + b := got[repB] + if b.Receptions != 2 || b.Spots != 1 || b.Estimate != nil { + t.Fatalf("repB = %+v, want 2 receptions in 1 spot and no estimate", b) + } + // Newest name report first. + if resp.Nodes[0].Pubkey != repA { + t.Errorf("order: first = %s, want repA", resp.Nodes[0].Pubkey) + } +} + +// A short rxlog hash shared with a node in nodes is ambiguous and must not be +// attributed to the reported repeater. +func TestReportedNodesSkipsAmbiguousPathHash(t *testing.T) { + srv := seedReported(t) + mustExecDB(t, srv.db, `INSERT INTO nodes (public_key, name, role) VALUES ('c0cd990000000000000000000000000000000000000000000000000000000000','Other','repeater')`) + resp := getReported(t, srv) + for _, n := range resp.Nodes { + if n.Pubkey == repA && n.Receptions != 3 { + t.Fatalf("repA receptions = %d, want 3 (the ambiguous c0cd rxlog row dropped)", n.Receptions) + } + } +} + +func TestReportedNodesBlacklisted(t *testing.T) { + srv := seedReported(t) + srv.cfg.NodeBlacklist = []string{repA} + for _, n := range getReported(t, srv).Nodes { + if n.Pubkey == repA { + t.Fatal("blacklisted repeater listed") + } + } +} + +// Without client_node_names (an older ingestor) the endpoint answers an empty +// list, not an error. +func TestReportedNodesWithoutTable(t *testing.T) { + srv := &Server{db: setupTestDBv2(t), cfg: &Config{}} + if resp := getReported(t, srv); len(resp.Nodes) != 0 { + t.Fatalf("nodes = %v, want none", resp.Nodes) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index f6e91e0e4..6805c41d0 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -42,6 +42,9 @@ type Server struct { commit string buildTime string + // reportedNodes caches /api/nodes/reported (reported_nodes.go). + reportedNodes reportedNodesCache + // Cached runtime.MemStats to avoid stop-the-world pauses on every health check memStatsMu sync.Mutex memStatsCache runtime.MemStats @@ -346,6 +349,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { // gated by requireClientRfSamples instead. r.HandleFunc("/api/rf-noise", s.handleRfNoise).Methods("GET") r.HandleFunc("/api/nodes/resolve", s.handleResolvePrefix).Methods("GET") + r.HandleFunc("/api/nodes/reported", s.handleReportedNodes).Methods("GET") r.HandleFunc("/api/rx-coverage", s.handleRxCoverage).Methods("GET") r.HandleFunc("/api/rx-leaderboard", s.handleRxLeaderboard).Methods("GET") r.HandleFunc("/api/nodes/{pubkey}", s.handleNodeDetail).Methods("GET") diff --git a/docs/client-rx-coverage.md b/docs/client-rx-coverage.md index 5ee2b727a..2d3aad8c9 100644 --- a/docs/client-rx-coverage.md +++ b/docs/client-rx-coverage.md @@ -179,6 +179,27 @@ set; it lands in `client_node_names`. - The broker ACL must allow `/names`. Explicit topic lists need `meshcore/client/+/names` (or `meshcore/client/#`). +#### Estimated positions + +A repeater known only from `/names` has no advert, so no position of its own. `GET +/api/nodes/reported` lists every such repeater (no advert position in `nodes` or `inactive_nodes`), +and the map draws an estimated position for the ones it can place. The **Estimated positions** +toggle under Display shows or hides them (on by default). + +- **Which receptions count:** rows in `client_receptions` that certainly heard this repeater + directly. Those are `discover` and `advert` rows keyed by its full pubkey or 8-byte prefix, and + `rxlog` rows whose 2-3 byte path hash resolves uniquely to it. `geo` rows are left out. +- **Spots:** receptions within 100 m of each other count as one spot, with the best SNR of that + spot, so a parked companion counts once. +- **Estimate:** only from 3 spots at least 200 m apart. Below that the list still shows the + repeater, without a position. The position is the SNR-weighted centroid of the spots (weight + `10^(snr/20)`). The radius is the distance to the farthest spot, never below 500 m. +- **On the map:** a hollow dashed marker with "β", a dashed circle with that radius, and a popup + that says "Estimated position" and how it was made. It is never clustered with advert markers. +- **Limitation:** this is a centroid of where companions drove, not trilateration. A repeater heard + from one side only is placed towards that side. The ingestor stores the receptions; the server + computes the estimate on request and caches it for 5 minutes. + ## Capture HARD RULE β only what was heard directly The app and ingestor record **only the node the companion physically received**, never upstream diff --git a/public/index.html b/public/index.html index bc9d5bd4f..6b96a94a6 100644 --- a/public/index.html +++ b/public/index.html @@ -218,6 +218,7 @@ + diff --git a/public/map-estimated.js b/public/map-estimated.js new file mode 100644 index 000000000..10f46013a --- /dev/null +++ b/public/map-estimated.js @@ -0,0 +1,81 @@ +/* Estimated positions for repeaters known only from their reported name + (/api/nodes/reported, cmd/server/reported_nodes.go). + + These repeaters never sent an advert, so they have no position of their own. + The server estimates one from where mobile companions heard them directly. + Everything here exists to keep that estimate from ever being read as a real + node position: a hollow dashed marker with "β", a dashed uncertainty circle, + and a popup that says what the position is and is not. */ +(function () { + 'use strict'; + + var esc = function (s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + }; + + // withEstimate keeps only the nodes the server could place. + function withEstimate(nodes) { + return (nodes || []).filter(function (n) { + return n && n.estimate && isFinite(n.estimate.lat) && isFinite(n.estimate.lon); + }); + } + + function formatRadius(m) { + if (!isFinite(m)) return ''; + return m >= 1000 ? (Math.round(m / 100) / 10) + ' km' : Math.round(m) + ' m'; + } + + function coverageHref(n) { + return '#/rx-coverage?days=30&lat=' + n.estimate.lat.toFixed(5) + '&lon=' + n.estimate.lon.toFixed(5) + '&zoom=14'; + } + + function iconHtml() { + return ''; + } + + function popupHtml(n, method) { + var e = n.estimate; + var name = n.name ? esc(n.name) : esc(n.pubkey.slice(0, 8)); + var minSpots = method && method.min_spots ? method.min_spots : 3; + return '
No advert position. This repeater only told a mobile companion its name, ' + + 'so its location is estimated from where companions heard it directly: within about ' + + esc(formatRadius(e.radius_m)) + ' of this point.
' + + 'A centroid of where companions drove, not a measured position: a repeater heard from one side only is placed towards that side.
' + + 'Show receptions on the coverage map' + + '