diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2f68250e..f6c4b18a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -682,6 +682,8 @@ jobs: CHROMIUM_REQUIRE=1 node tests/e2e/test-neighbor-map-btn-clip-e2e.js 2>&1 | tee -a e2e-output.txt echo "=== E2E SUITE: test-rx-coverage-viewport-e2e.js ===" BASE_URL=http://localhost:13581 node tests/e2e/test-rx-coverage-viewport-e2e.js 2>&1 | tee -a e2e-output.txt + echo "=== E2E SUITE: test-rx-coverage-noise-e2e.js ===" + BASE_URL=http://localhost:13581 node tests/e2e/test-rx-coverage-noise-e2e.js 2>&1 | tee -a e2e-output.txt # Issue #2037: these eight sat in tests/e2e/ with no runner at all. # Measured on upstream master in CI run 35340835178: all eight pass, # 45 seconds together. test-rx-coverage-mobile-nav-e2e.js is the diff --git a/cmd/server/config.go b/cmd/server/config.go index f6e22ee9..591a9a55 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -173,6 +173,13 @@ type Config struct { // ⇒ off; see ClientRxCoverageEnabled. ClientRxCoverage *ClientRxCoverageConfig `json:"clientRxCoverage,omitempty"` + // ClientRfSamples gates the opt-in RF environment sample stream (companion + // radio noise-floor/counters paired with GPS, table client_rf_samples). + // This is the same flag cmd/ingestor checks before recording samples; + // mirrored here so the server can gate reading them back. Absent/nil ⇒ + // off; see ClientRfSamplesEnabled. + ClientRfSamples *ClientRfSamplesConfig `json:"clientRfSamples,omitempty"` + ResolvedPath *ResolvedPathConfig `json:"resolvedPath,omitempty"` NeighborGraph *NeighborGraphConfig `json:"neighborGraph,omitempty"` @@ -273,6 +280,20 @@ func (c *Config) ClientRxCoverageEnabled() bool { return c != nil && c.ClientRxCoverage != nil && c.ClientRxCoverage.Enabled } +// ClientRfSamplesConfig gates the opt-in RF environment sample stream. +// Mirrors cmd/ingestor's ClientRfSamplesConfig (same JSON section, read by +// both binaries from the same config.json). +type ClientRfSamplesConfig struct { + Enabled bool `json:"enabled"` +} + +// ClientRfSamplesEnabled reports whether the opt-in RF environment sample +// stream is on. Nil config or absent/nil section ⇒ off (the safe default), +// mirroring ClientRxCoverageEnabled. +func (c *Config) ClientRfSamplesEnabled() bool { + return c != nil && c.ClientRfSamples != nil && c.ClientRfSamples.Enabled +} + // WSCompressionEnabled returns true when WebSocket permessage-deflate is explicitly enabled. func (c *Config) WSCompressionEnabled() bool { return c.Compression != nil && c.Compression.Websocket diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 924f23e5..09aaad61 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -93,6 +93,12 @@ func routeDescriptions() map[string]routeMeta { }}, "GET /api/nodes/{pubkey}/neighbors": {Summary: "Get node neighbors", Description: "Returns the queried node's first-hop neighbors with affinity scores and observation metadata (count, SNR, distance, observers). Ambiguous edges carry candidate pubkeys.", Tag: "nodes", Response: schemaRef("NodeNeighborsResponse")}, + "GET /api/rf-noise": {Summary: "RF noise-floor hex grid", Description: "GeoJSON hex cells of the LoRa noise floor measured by mobile clients along their tracks, from client_rf_samples. Lower (more negative) dBm is quieter, the opposite direction to the SNR-coloured coverage layer. Stationary samples are excluded: a parked companion logs hundreds of samples at one point and would otherwise define the cell. Gated on clientRfSamples.", Tag: "coverage", + QueryParams: []paramMeta{ + {Name: "bbox", Description: "Bounding box as minLat,minLon,maxLat,maxLon", Type: "string", Required: true}, + {Name: "z", Description: "Leaflet zoom level; sets the hex resolution", Type: "integer"}, + {Name: "days", Description: "Look-back window in days (1-30, default 7)", Type: "integer"}, + }}, "GET /api/scope-audit": {Summary: "Network-wide scope audit", Description: "For every repeater that has answered a declared-regions request: the regions it declares, which of those it has NOT been observed forwarding in the window, which scopes it forwards without declaring, and whether it forwards unscoped floods while omitting the '*' wildcard. '*' is never listed as a region — it governs unscoped floods, not a scope. Repeaters never successfully asked are absent rather than shown as declaring nothing. Rows with missing regions sort first; a short window is weak evidence, since a quiet region simply has no traffic.", Tag: "analytics", QueryParams: []paramMeta{ {Name: "window", Description: "Time window: 1h, 24h, or 7d (default 24h)", Type: "string"}, diff --git a/cmd/server/rf_noise.go b/cmd/server/rf_noise.go new file mode 100644 index 00000000..ef49f8f1 --- /dev/null +++ b/cmd/server/rf_noise.go @@ -0,0 +1,201 @@ +package main + +import ( + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +// rfNoiseRow is one raw client_rf_samples reading: a companion's own noise +// floor reading paired with its GPS fix. +type rfNoiseRow struct { + Lat, Lon float64 + NoiseFloor int + Stationary bool +} + +// rfNoiseFeatureCap bounds the number of hex cells returned in one response, +// mirroring coverageFeatureCap (#12) for the same reason: a wide bbox at high +// zoom over the 30-day window could otherwise emit unbounded GeoJSON. +const rfNoiseFeatureCap = 5000 + +// RfNoiseFeatureCollection is the GeoJSON payload for GET /api/rf-noise. +// Truncated is a non-standard foreign member (ignored by GeoJSON consumers +// like Leaflet) that signals the cell list was capped at rfNoiseFeatureCap. +type RfNoiseFeatureCollection struct { + Type string `json:"type"` // "FeatureCollection" + Features []RfNoiseFeature `json:"features"` + Truncated bool `json:"truncated,omitempty"` +} + +type RfNoiseFeature struct { + Type string `json:"type"` // "Feature" + Geometry CoveragePolygon `json:"geometry"` + Properties RfNoiseProperties `json:"properties"` +} + +// RfNoiseProperties summarizes one cell's noise-floor samples. NoiseFloor +// values are dBm and negative; lower (more negative) is quieter — the +// opposite direction from the SNR the coverage layer colours. +type RfNoiseProperties struct { + Cell string `json:"cell"` + Count int `json:"count"` + // MedianNoiseFloor is the MEDIAN (not mean) of the cell's samples: a + // single anomalous reading (a burst of local interference at one GPS + // point) shouldn't drag the whole cell's reported value the way it would + // pull a mean. + MedianNoiseFloor float64 `json:"median_noise_floor"` + QuietestNoiseFloor int `json:"quietest_noise_floor"` // min sample in the cell (best/quietest) + NoisiestNoiseFloor int `json:"noisiest_noise_floor"` // max sample in the cell (worst/busiest) +} + +type rfNoiseAgg struct { + values []int // noise_floor samples retained for this cell (mobile-only, see aggregateRfNoise) +} + +// aggregateRfNoise bins raw RF-environment samples into display-resolution +// hex cells and emits GeoJSON polygons carrying the noise-floor distribution +// per cell. +// +// Stationary samples (stationary=1, a parked companion) are dropped entirely +// rather than down-weighted: a single parked driver can log hundreds of +// samples at one GPS point in the time a drive-by logs a handful, so any +// inclusion — even down-weighted — still lets the parked run dominate a +// cell's reported noise floor. This layer exists to show what the band looks +// like from the road; a stationary run isn't that measurement. +func aggregateRfNoise(rows []rfNoiseRow, res int) RfNoiseFeatureCollection { + byCell := map[string]*rfNoiseAgg{} + for _, row := range rows { + if row.Stationary { + continue + } + cell := hexCellAt(row.Lat, row.Lon, res) + a := byCell[cell] + if a == nil { + a = &rfNoiseAgg{} + byCell[cell] = a + } + a.values = append(a.values, row.NoiseFloor) + } + fc := RfNoiseFeatureCollection{Type: "FeatureCollection", Features: []RfNoiseFeature{}} + for cell, a := range byCell { + ring := hexBoundary(cell) + if ring == nil { + continue + } + sort.Ints(a.values) + fc.Features = append(fc.Features, RfNoiseFeature{ + Type: "Feature", + Geometry: CoveragePolygon{Type: "Polygon", Coordinates: [][][2]float64{ring}}, + Properties: RfNoiseProperties{ + Cell: cell, + Count: len(a.values), + MedianNoiseFloor: medianInt(a.values), + QuietestNoiseFloor: a.values[0], + NoisiestNoiseFloor: a.values[len(a.values)-1], + }, + }) + } + // Bound the response the same way aggregateCoverage does (#12): when more + // cells exist than rfNoiseFeatureCap, keep the densest (highest count) + // and flag the truncation. + if len(fc.Features) > rfNoiseFeatureCap { + 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[:rfNoiseFeatureCap] + fc.Truncated = true + } + // Map iteration is randomized, so sort by cell for a deterministic + // payload, same as aggregateCoverage (#8). + sort.Slice(fc.Features, func(i, j int) bool { + return fc.Features[i].Properties.Cell < fc.Features[j].Properties.Cell + }) + return fc +} + +// medianInt returns the median of vals (average of the two middle values +// when the count is even). vals must be sorted ascending and non-empty. +func medianInt(vals []int) float64 { + n := len(vals) + if n%2 == 1 { + return float64(vals[n/2]) + } + return float64(vals[n/2-1]+vals[n/2]) / 2 +} + +// requireClientRfSamples writes a 404 and returns false when the opt-in RF +// sample stream is disabled, mirroring requireClientRxCoverage so the +// endpoint reads as "not found" instead of serving data on deployments that +// haven't enabled it. +func (s *Server) requireClientRfSamples(w http.ResponseWriter, r *http.Request) bool { + if s == nil || s.cfg == nil || !s.cfg.ClientRfSamplesEnabled() { + http.NotFound(w, r) + return false + } + return true +} + +// queryRfNoiseRows returns raw RF-environment samples within a bbox, over a +// time window (days; 0 = all time). Read-only (server RO connection). +func (s *Server) queryRfNoiseRows(days int, b bbox) ([]rfNoiseRow, error) { + where := []string{"lat BETWEEN ? AND ?", "lon BETWEEN ? AND ?", "noise_floor IS NOT NULL"} + args := []interface{}{b.MinLat, b.MaxLat, b.MinLon, b.MaxLon} + if days > 0 { + since := time.Now().UTC().AddDate(0, 0, -days).Format(time.RFC3339) + where = append(where, "sampled_at >= ?") + args = append(args, since) + } + rows, err := s.db.conn.Query(` + SELECT lat, lon, noise_floor, stationary + FROM client_rf_samples + WHERE `+strings.Join(where, " AND "), args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []rfNoiseRow{} + for rows.Next() { + var lat, lon float64 + var noiseFloor, stationary int + if err := rows.Scan(&lat, &lon, &noiseFloor, &stationary); err != nil { + return nil, err + } + out = append(out, rfNoiseRow{Lat: lat, Lon: lon, NoiseFloor: noiseFloor, Stationary: stationary != 0}) + } + return out, rows.Err() +} + +// handleRfNoise serves the RF noise-floor map layer as GeoJSON hex cells, +// over a time window. Mirrors handleRxCoverage's parameter handling. +func (s *Server) handleRfNoise(w http.ResponseWriter, r *http.Request) { + if !s.requireClientRfSamples(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 + } + days := clampDays(atoiDefault(r.URL.Query().Get("days"), 7)) + z, _ := strconv.Atoi(r.URL.Query().Get("z")) + rows, err := s.queryRfNoiseRows(days, b) + if err != nil { + http.Error(w, "query failed", http.StatusInternalServerError) + return + } + fc := aggregateRfNoise(rows, zoomToHexRes(z)) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(fc) +} diff --git a/cmd/server/rf_noise_test.go b/cmd/server/rf_noise_test.go new file mode 100644 index 00000000..a3c1f320 --- /dev/null +++ b/cmd/server/rf_noise_test.go @@ -0,0 +1,264 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/mux" +) + +// TestAggregateRfNoiseExcludesStationary verifies the stationary-sample +// decision documented on aggregateRfNoise: a parked companion can log +// hundreds of samples at one GPS point, and those must not enter the +// aggregate at all (not even down-weighted) — otherwise the parked run +// would dominate the cell's reported noise floor. +func TestAggregateRfNoiseExcludesStationary(t *testing.T) { + rows := []rfNoiseRow{ + // One real drive-by sample. + {Lat: 51.05, Lon: 3.72, NoiseFloor: -110, Stationary: false}, + } + // A parked companion logging 300 samples at a much noisier value — if + // these leaked into the aggregate, the median would swing hard toward -90. + for i := 0; i < 300; i++ { + rows = append(rows, rfNoiseRow{Lat: 51.05, Lon: 3.72, NoiseFloor: -90, Stationary: true}) + } + fc := aggregateRfNoise(rows, 9) + if len(fc.Features) != 1 { + t.Fatalf("expected 1 cell, got %d", len(fc.Features)) + } + p := fc.Features[0].Properties + if p.Count != 1 { + t.Fatalf("stationary rows leaked into the aggregate: count=%d, want 1", p.Count) + } + if p.MedianNoiseFloor != -110 || p.QuietestNoiseFloor != -110 || p.NoisiestNoiseFloor != -110 { + t.Fatalf("stationary rows skewed the stats: %+v", p) + } +} + +// TestAggregateRfNoiseMedianAndExtremes verifies the per-cell summary stats +// on a mix of mobile samples: count, median (average of the two middle +// values for an even count), quietest (min) and noisiest (max). +func TestAggregateRfNoiseMedianAndExtremes(t *testing.T) { + rows := []rfNoiseRow{ + {Lat: 51.05, Lon: 3.72, NoiseFloor: -116}, + {Lat: 51.05, Lon: 3.72, NoiseFloor: -110}, + {Lat: 51.05, Lon: 3.72, NoiseFloor: -108}, + {Lat: 51.05, Lon: 3.72, NoiseFloor: -103}, + } + fc := aggregateRfNoise(rows, 9) + if len(fc.Features) != 1 { + t.Fatalf("expected 1 cell, got %d", len(fc.Features)) + } + p := fc.Features[0].Properties + if p.Count != 4 { + t.Fatalf("count = %d, want 4", p.Count) + } + if p.MedianNoiseFloor != -109 { // avg(-110, -108) + t.Fatalf("median = %v, want -109", p.MedianNoiseFloor) + } + if p.QuietestNoiseFloor != -116 { + t.Fatalf("quietest = %d, want -116", p.QuietestNoiseFloor) + } + if p.NoisiestNoiseFloor != -103 { + t.Fatalf("noisiest = %d, want -103", p.NoisiestNoiseFloor) + } +} + +// TestAggregateRfNoiseCapsFeatures verifies the rfNoiseFeatureCap truncation +// (mirrors #12 on the coverage aggregator): a query spanning more than +// rfNoiseFeatureCap cells is bounded, with Truncated set, and a smaller +// query is not truncated. +func TestAggregateRfNoiseCapsFeatures(t *testing.T) { + rows := make([]rfNoiseRow, 0, rfNoiseFeatureCap+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, rfNoiseRow{Lat: lat, Lon: lon, NoiseFloor: -110}) + } + // Seed one cell with a much higher sample count than the rest (#5): every + // other cell above has count == 1, so a cap comparator that only exercises + // the cell-id tie-break (or is inverted to keep the sparsest cells) would + // still pass without this. This cell — the busiest by far — must be the + // one the cap keeps, not the one it cuts. + hotLat, hotLon := 10.0, 10.0 + for i := 0; i < 50; i++ { + rows = append(rows, rfNoiseRow{Lat: hotLat, Lon: hotLon, NoiseFloor: -110}) + } + fc := aggregateRfNoise(rows, 9) + if len(fc.Features) != rfNoiseFeatureCap || !fc.Truncated { + t.Fatalf("want %d features + truncated, got %d truncated=%v", rfNoiseFeatureCap, len(fc.Features), fc.Truncated) + } + 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) + } + } + hotCell := hexCellAt(hotLat, hotLon, 9) + found := false + for _, f := range fc.Features { + if f.Properties.Cell == hotCell { + found = true + if f.Properties.Count < 51 { + t.Fatalf("hot cell count = %d, want >= 51", f.Properties.Count) + } + } + } + if !found { + t.Fatalf("the densest cell was dropped by the cap; want the densest cells kept") + } + small := aggregateRfNoise(rows[:10], 9) + if small.Truncated { + t.Fatalf("small query should not be truncated") + } +} + +// TestAggregateRfNoiseEmpty verifies an empty input produces an empty (not +// nil) feature collection, matching aggregateCoverage's shape. +func TestAggregateRfNoiseEmpty(t *testing.T) { + fc := aggregateRfNoise(nil, 9) + if fc.Type != "FeatureCollection" || fc.Features == nil || len(fc.Features) != 0 { + t.Fatalf("unexpected empty aggregate: %+v", fc) + } + if fc.Truncated { + t.Fatalf("empty input should not be truncated") + } +} + +// --- Gate: /api/rf-noise and clientRfSamples in /api/config/client --- + +func rfNoiseGateTestServer(t *testing.T, rf *ClientRfSamplesConfig) *mux.Router { + t.Helper() + db := setupTestDB(t) + cfg := &Config{Port: 3000, ClientRfSamples: rf} + hub := NewHub() + srv := NewServer(db, cfg, hub) + router := mux.NewRouter() + srv.RegisterRoutes(router) + return router +} + +func TestRfNoiseRouteGatedOff(t *testing.T) { + router := rfNoiseGateTestServer(t, nil) + + req := httptest.NewRequest("GET", "/api/rf-noise", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != 404 { + t.Fatalf("expected 404 for /api/rf-noise when disabled, got %d", rr.Code) + } + + creq := httptest.NewRequest("GET", "/api/config/client", nil) + crr := httptest.NewRecorder() + router.ServeHTTP(crr, creq) + if crr.Code != 200 { + t.Fatalf("config/client status %d body %s", crr.Code, crr.Body.String()) + } + if !strings.Contains(crr.Body.String(), `"clientRfSamples":false`) { + t.Fatalf("expected clientRfSamples:false in config body, got %s", crr.Body.String()) + } +} + +func TestRfNoiseRouteGatedOn(t *testing.T) { + router := rfNoiseGateTestServer(t, &ClientRfSamplesConfig{Enabled: true}) + + req := httptest.NewRequest("GET", "/api/rf-noise", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code == 404 { + t.Fatalf("expected /api/rf-noise to be registered when enabled, got 404") + } + + creq := httptest.NewRequest("GET", "/api/config/client", nil) + crr := httptest.NewRecorder() + router.ServeHTTP(crr, creq) + if crr.Code != 200 { + t.Fatalf("config/client status %d body %s", crr.Code, crr.Body.String()) + } + if !strings.Contains(crr.Body.String(), `"clientRfSamples":true`) { + t.Fatalf("expected clientRfSamples:true in config body, got %s", crr.Body.String()) + } +} + +// --- End-to-end: query + aggregation through the handler --- + +func seedRfNoiseDB(t *testing.T) *DB { + db := setupTestDBv2(t) + mustExecDB(t, db, `CREATE TABLE client_rf_samples ( + id INTEGER PRIMARY KEY AUTOINCREMENT, rx_pubkey TEXT, sampled_at TEXT, ingested_at TEXT, + lat REAL, lon REAL, pos_acc_m REAL, stationary INTEGER NOT NULL DEFAULT 0, + uptime_secs INTEGER, battery_mv INTEGER, queue_len INTEGER, errors INTEGER, + noise_floor INTEGER, last_rssi INTEGER, last_snr REAL, tx_air_secs INTEGER, + rx_air_secs INTEGER, recv INTEGER, sent INTEGER, flood_rx INTEGER, direct_rx INTEGER, + flood_tx INTEGER, direct_tx INTEGER, recv_errors INTEGER)`) + return db +} + +func serveRfNoise(srv *Server, path string) *httptest.ResponseRecorder { + router := mux.NewRouter() + router.HandleFunc("/api/rf-noise", srv.handleRfNoise).Methods("GET") + req := httptest.NewRequest("GET", path, nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr +} + +func TestRfNoiseEndpointGeoJSON(t *testing.T) { + db := seedRfNoiseDB(t) + now := time.Now().UTC().Format(time.RFC3339) + outsideWindow := time.Now().UTC().AddDate(0, 0, -40).Format(time.RFC3339) + mustExecDB(t, db, `INSERT INTO client_rf_samples (rx_pubkey,sampled_at,ingested_at,lat,lon,stationary,uptime_secs,noise_floor) + VALUES ('comp','`+now+`','t',51.05,3.72,0,100,-112)`) + // A parked sample in the same cell/window must not affect the response. + mustExecDB(t, db, `INSERT INTO client_rf_samples (rx_pubkey,sampled_at,ingested_at,lat,lon,stationary,uptime_secs,noise_floor) + VALUES ('comp','`+now+`','t',51.05,3.72,1,100,-90)`) + // A row with no noise_floor reading must be skipped, not counted/crash. + mustExecDB(t, db, `INSERT INTO client_rf_samples (rx_pubkey,sampled_at,ingested_at,lat,lon,stationary,uptime_secs,noise_floor) + VALUES ('comp','`+now+`','t',51.05,3.72,0,100,NULL)`) + // A row outside the days=30 window (#3) must not be counted. + mustExecDB(t, db, `INSERT INTO client_rf_samples (rx_pubkey,sampled_at,ingested_at,lat,lon,stationary,uptime_secs,noise_floor) + VALUES ('comp','`+outsideWindow+`','t',51.05,3.72,0,100,-105)`) + // A row outside the bbox=50,3,52,4 window (#3) must not be counted. + mustExecDB(t, db, `INSERT INTO client_rf_samples (rx_pubkey,sampled_at,ingested_at,lat,lon,stationary,uptime_secs,noise_floor) + VALUES ('comp','`+now+`','t',10.0,10.0,0,100,-105)`) + srv := &Server{db: db, cfg: &Config{ClientRfSamples: &ClientRfSamplesConfig{Enabled: true}}} + + rr := serveRfNoise(srv, "/api/rf-noise?bbox=50,3,52,4&z=12&days=30") + if rr.Code != 200 { + t.Fatalf("status %d body %s", rr.Code, rr.Body.String()) + } + var fc RfNoiseFeatureCollection + if err := json.Unmarshal(rr.Body.Bytes(), &fc); err != nil { + t.Fatalf("decode: %v", err) + } + if fc.Type != "FeatureCollection" || len(fc.Features) != 1 { + t.Fatalf("unexpected fc: %+v", fc) + } + p := fc.Features[0].Properties + if p.Count != 1 || p.MedianNoiseFloor != -112 { + t.Fatalf("expected the single mobile sample to define the cell, got %+v", p) + } + + if serveRfNoise(srv, "/api/rf-noise").Code != 400 { + t.Fatal("missing bbox should be 400") + } +} + +// TestRfNoiseEndpointGatedOff404sEvenWithData verifies the endpoint 404s +// when the feature flag is off even though matching rows exist in the DB. +// It does not verify requireClientRfSamples runs before the DB query (a gate +// checked after the query would also 404 here) — that ordering is correct +// by inspection of handleRfNoise, not exercised by this test. +func TestRfNoiseEndpointGatedOff404sEvenWithData(t *testing.T) { + db := seedRfNoiseDB(t) + mustExecDB(t, db, `INSERT INTO client_rf_samples (rx_pubkey,sampled_at,ingested_at,lat,lon,stationary,uptime_secs,noise_floor) + VALUES ('comp','2026-06-01T10:00:00Z','t',51.05,3.72,0,100,-112)`) + srv := &Server{db: db, cfg: &Config{}} + + if code := serveRfNoise(srv, "/api/rf-noise?bbox=50,3,52,4"); code.Code != 404 { + t.Fatalf("expected 404 when clientRfSamples disabled, got %d", code.Code) + } +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 76e6fd24..e69d4529 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -310,6 +310,10 @@ func (s *Server) RegisterRoutes(r *mux.Router) { // clientRxCoverage flag is off (a clean 404 rather than the SPA fallback that // an unregistered /api route would hit). See requireClientRxCoverage. r.HandleFunc("/api/nodes/{pubkey}/rx-coverage", s.handleNodeRxCoverage).Methods("GET") + + // Same registered-unconditionally / 404-when-off pattern as coverage above, + // 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/rx-coverage", s.handleRxCoverage).Methods("GET") r.HandleFunc("/api/rx-leaderboard", s.handleRxLeaderboard).Methods("GET") @@ -490,6 +494,7 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) { Tiles: s.cfg.Tiles, Customizer: CustomizerClientConfig{DisabledTabs: disabledTabs}, ClientRxCoverage: s.cfg.ClientRxCoverageEnabled(), + ClientRfSamples: s.cfg.ClientRfSamplesEnabled(), PathTrust: &pathTrust, }) } diff --git a/cmd/server/types.go b/cmd/server/types.go index bc23faef..f8efd466 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -1082,6 +1082,7 @@ type ClientConfigResponse struct { MapDarkTileProvider string `json:"mapDarkTileProvider,omitempty"` // deprecated. TODO: remove after v3.5.0 Customizer CustomizerClientConfig `json:"customizer"` ClientRxCoverage bool `json:"clientRxCoverage"` + ClientRfSamples bool `json:"clientRfSamples"` PathTrust *PathTrustConfig `json:"pathTrust,omitempty"` } diff --git a/docs/api-spec.md b/docs/api-spec.md index 9ae92975..118ec625 100644 --- a/docs/api-spec.md +++ b/docs/api-spec.md @@ -34,6 +34,7 @@ - [GET /api/observers/:id/analytics](#get-apiobserversidanalytics) - [GET /api/channels](#get-apichannels) - [GET /api/channels/:hash/messages](#get-apichannelshashmessages) +- [GET /api/rf-noise](#get-apirf-noise) - [GET /api/analytics/rf](#get-apianalyticsrf) - [GET /api/analytics/topology](#get-apianalyticstopology) - [GET /api/analytics/retransmissions](#get-apianalyticsretransmissions) @@ -1278,6 +1279,68 @@ RF signal analytics. --- +## GET /api/rf-noise + +RF noise-floor map layer: where the LoRa band is quiet vs. busy, as measured +by mobile companions' own radio counters along a driver's track. Opt-in, gated +by `config.json`'s `clientRfSamples.enabled` (the same flag `cmd/ingestor` uses +to decide whether to record the underlying `client_rf_samples` rows); the +endpoint 404s when disabled. The response shape mirrors the `/api/rx-coverage` +hex-grid GeoJSON, but reports noise floor (dBm, negative; **lower is quieter**, +the opposite direction from the SNR `rx-coverage` colours) instead of signal. + +Stationary samples (a parked companion) are excluded from the aggregate +entirely: a single parked driver can log hundreds of samples at one GPS +point, which would otherwise dominate a cell's reported noise floor and +misrepresent what the band looks like from the road. + +**Deployment note:** this endpoint is gated only by `clientRfSamples.enabled` +and has no dependency on `clientRxCoverage.enabled`. Enabling +`clientRfSamples` without also enabling `clientRxCoverage` leaves +`/api/rf-noise` live and returning data with no UI to reach it: the +Coverage page (which hosts the Noise layer toggle) stops at "Coverage is +not enabled" before the toggle is rendered. + +### Query Parameters + +| Param | Type | Default | Description | +|--------|--------|---------|-----------------------------------------------------------| +| `bbox` | string | - | **Required.** `minLat,minLon,maxLat,maxLon` | +| `z` | number | - | Leaflet zoom level; selects the hex display resolution | +| `days` | number | `7` | Lookback window by `sampled_at`, clamped to `[1,30]` | + +### Response `200` + +```jsonc +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Polygon", "coordinates": [ [ [lon, lat], ... ] ] }, + "properties": { + "cell": string, // hex cell id + "count": number, // mobile (non-stationary) samples in this cell + "median_noise_floor": number, // dBm; median of the cell's samples + "quietest_noise_floor": number, // dBm; the cell's minimum (best) sample + "noisiest_noise_floor": number // dBm; the cell's maximum (worst) sample + } + } + ], + "truncated": boolean // true when more cells existed than the response cap; densest cells are kept +} +``` + +### Response `404` + +Returned when `clientRfSamples.enabled` is not `true` in `config.json`. + +### Response `400` + +Returned when `bbox` is missing or malformed. + +--- + ## GET /api/analytics/topology Network topology analytics. @@ -2192,7 +2255,8 @@ Client-side configuration values. "wsReconnectMs": number | null, "cacheInvalidateMs": number | null, "externalUrls": object | null, - "propagationBufferMs": number // default: 5000 + "propagationBufferMs": number, // default: 5000 + "clientRfSamples": boolean // opt-in flag; gates /api/rf-noise } ``` diff --git a/public/roles.js b/public/roles.js index 20850a1b..3c297e31 100644 --- a/public/roles.js +++ b/public/roles.js @@ -584,6 +584,7 @@ // ─── Fetch server overrides ─── window.MeshConfigReady = fetch('/api/config/client').then(function (r) { return r.json(); }).then(function (cfg) { window.MC_CLIENT_RX_COVERAGE = cfg.clientRxCoverage === true; + window.MC_CLIENT_RF_SAMPLES = cfg.clientRfSamples === true; // Coverage is opt-in: the nav link is NOT in static HTML (so the default-off // nav matches upstream and the nav-overflow tests). Inject it after Analytics // only when enabled, then nudge applyNavPriority (it re-runs on 'resize'). diff --git a/public/rx-coverage.js b/public/rx-coverage.js index 1d1b12ad..a1ebb665 100644 --- a/public/rx-coverage.js +++ b/public/rx-coverage.js @@ -7,6 +7,9 @@ 'use strict'; (function () { var map = null, covLayer = null, days = 7, selectedRx = '', selectedName = '', boardCache = [], destroyed = false, generation = 0; + // layer: 'signal' (default, per-cell best SNR of directly-heard nodes) or + // 'noise' (RF noise-floor layer, opt-in — see MC_CLIENT_RF_SAMPLES). + var layer = 'signal'; function cssColor(varName) { try { return getComputedStyle(document.documentElement).getPropertyValue(varName).trim() || '#888'; } @@ -24,13 +27,67 @@ function dayBtn(d) { return ''; } + function layerBtn(k, label) { return ''; } + + // NOISE_QUIET_MAX / NOISE_BUSY_MIN (dBm) bucket the noise layer into 3 tiers. + // Fitted to observed data (#4): 1241 production moving samples + // (stationary=0) ranged -120..-88, mass concentrated -113..-116. Cumulative + // counts: <= -115 → 504 (41%), -114..-108 → 581 (47%), >= -107 → ~156 (12%). + // -115/-108 puts all three tiers within reach of that distribution instead + // of leaving "busy" almost never rendered. + var NOISE_QUIET_MAX = -115; // <= this: quiet + var NOISE_BUSY_MIN = -108; // > this: busy + + // noiseColorVar reuses the coverage colour tokens (green=good, red=bad), but + // the axis is INVERTED versus colorVar's SNR check: for noise floor, a LOW + // (more negative) dBm reading is quieter/better, so it maps to the "strong" + // (green) token, and a HIGH reading maps to "weak" (red) -- the opposite + // comparison direction from colorVar. + function noiseColorVar(p) { + var m = Number(p.median_noise_floor); + if (m <= NOISE_QUIET_MAX) return '--nq-cov-strong'; + if (m <= NOISE_BUSY_MIN) return '--nq-cov-mid'; + return '--nq-cov-weak'; + } + + function legendHtml() { + if (layer === 'noise') { + return '