From 000d9ab030ef6ca84fde298535829cafc4eec2eb Mon Sep 17 00:00:00 2001 From: efiten Date: Sun, 4 Oct 2026 22:11:34 +0200 Subject: [PATCH] feat(coverage): RF noise-floor layer on the Mobile RX coverage page (#2113) The ingestor already stores the noise floor that CoreDrive RX companions report with each GPS fix (`client_rf_samples`, opt-in through `clientRfSamples`, `cmd/ingestor/client_rf_sample.go:62`), but nothing reads it back. An operator who enables it collects the data and cannot see it. This adds the read side: `GET /api/rf-noise` and a Signal/Noise toggle on the Mobile RX coverage page. ## What it does - **`GET /api/rf-noise?bbox=&z=&days=`** returns a GeoJSON hex grid with the median, quietest and noisiest noise floor per cell, in the same shape as `/api/rx-coverage`. It is registered always and 404s unless `clientRfSamples.enabled` is true, the same pattern as the coverage routes. - **Stationary samples are excluded.** A parked companion logs hundreds of readings at one point, which would otherwise define its cell. - **Coverage page:** a Signal/Noise toggle, rendered only when `/api/config/client` reports `clientRfSamples: true`. The noise layer reuses the coverage colour tokens with the axis inverted, because a lower dBm is quieter. Tiers are at -115 and -108 dBm. - **Deep link:** `#/rx-coverage?layer=noise` opens on the noise layer. - **Empty and failed answers** are labelled on the map ("No RF samples in this view yet", or a retry hint), so a blank map never reads as "feature off". No new configuration key: it reads the existing `clientRfSamples` section that the ingestor already uses. Default off, so nothing changes for an instance that has not opted in. ## Where it comes from Ported from the efiten/CoreScope fork, where it has run on analyzer.on8ar.eu since September (fork commits `42d09f0e`, `80581c43`, `cde95078`). The cherry-picks conflicted with upstream's newer `routes.go`, `types.go` and `rx-coverage.js`, so the final state was ported by hand. The fork-only `/scopes` route that sat next to it in the same hunk is deliberately left out. ## Performance The query is bounded by `sampled_at` (indexed, `idx_crf_prune`) and the bbox, aggregation is one pass plus a per-cell sort, and the response is capped at 5000 cells. Measured on analyzer.on8ar.eu, all of Belgium (`bbox=49.4,2.4,51.6,6.5&z=9`), from a client in Belgium, so network time included: | window | samples | cells | response time | |---|---|---|---| | 7 days | 10,835 | 241 | 0.37 s | | 30 days | 43,303 | 429 | 0.39 s | That table holds 45,270 rows in total. It is only read when someone opens the noise layer, never on ingest or WebSocket paths. ## Tests - `cmd/server/rf_noise_test.go`: 8 tests for the aggregation (median and extremes, stationary exclusion, the cell cap, empty input) and the gate (404 when off, even with data present). All pass, and the full `cmd/server` suite passes locally. - `tests/unit/test-rx-coverage-noise.js` (new, in `test-all.sh`): the colour axis runs the right way, including both tier bounds and a string median from the API. Slices the real function out of `rx-coverage.js` and fails on master's copy, which has no thresholds. - `tests/e2e/test-rx-coverage-noise-e2e.js` (new, wired in `deploy.yml` and `scripts/non-unit-tests.json`): no toggle when the flag is off; Noise fetches `/api/rf-noise`, draws the cells, swaps legend and subtitle, puts `layer=noise` in the hash; a deep link opens on the noise layer and an empty answer shows the message. 3/3 locally; against master's `rx-coverage.js` and `roles.js`, 1/3 (only the "flag off" case passes, as it should). - `test-rx-coverage-viewport-e2e.js` still passes. ESLint 8 reports 0 errors on the changed files. ## Not done - The thresholds (-115 / -108 dBm) are fitted to the fork's own data (1,241 moving samples at the time) and are constants in `rx-coverage.js`. Per AGENTS.md rule 8 they belong in the customizer eventually; not in this PR. - The E2E suite mocks the API. The real endpoint is covered by the Go tests and by the measurements above, not by a fixture with seeded samples. Co-authored-by: Claude Opus 5.5 --- .github/workflows/deploy.yml | 2 + cmd/server/config.go | 21 ++ cmd/server/openapi.go | 6 + cmd/server/rf_noise.go | 201 ++++++++++++++++++ cmd/server/rf_noise_test.go | 264 ++++++++++++++++++++++++ cmd/server/routes.go | 5 + cmd/server/types.go | 1 + docs/api-spec.md | 66 +++++- public/roles.js | 1 + public/rx-coverage.js | 177 +++++++++++++++- scripts/non-unit-tests.json | 1 + test-all.sh | 1 + tests/e2e/test-rx-coverage-noise-e2e.js | 91 ++++++++ tests/unit/test-rx-coverage-noise.js | 56 +++++ 14 files changed, 881 insertions(+), 12 deletions(-) create mode 100644 cmd/server/rf_noise.go create mode 100644 cmd/server/rf_noise_test.go create mode 100644 tests/e2e/test-rx-coverage-noise-e2e.js create mode 100644 tests/unit/test-rx-coverage-noise.js 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 '
' + + 'quiet (≤ ' + NOISE_QUIET_MAX + ' dBm)' + + 'medium' + + 'busy (> ' + NOISE_BUSY_MIN + ' dBm)' + + '
'; + } + return '
' + + 'strong' + + 'medium' + + 'weak' + + 'no signal' + + '
'; + } + + // subtitleHtml reflects the active layer (#7): the "Colour = ..." clause + // must not keep claiming best-signal colouring while the noise legend (an + // inverted, opposite-direction colour axis) sits right below it. + function subtitleHtml() { + var colourText = layer === 'noise' ? 'Colour = median RF noise floor per cell.' : 'Colour = best signal per cell.'; + return 'Where roaming CoreScope-RX clients heard nodes. ' + colourText + ' Get the companion app →'; + } + function pageHtml() { + var layerBar = window.MC_CLIENT_RF_SAMPLES + ? '
' + layerBtn('signal', 'Signal') + layerBtn('noise', 'Noise') + '
' + : ''; return '
' + '

Mobile RX coverage

' + - '
Where roaming CoreScope-RX clients heard nodes. Colour = best signal per cell. Get the companion app →
' + + '
' + subtitleHtml() + '
' + '
' + dayBtn(1) + dayBtn(7) + dayBtn(14) + dayBtn(30) + '
' + - '
strongmediumweakno signal
' + + layerBar + + legendHtml() + + '
' + '
' + + '' + + '
' + '
Top mobile observers
' + '
' + '
'; @@ -74,21 +131,94 @@ } } - function drawCoverage() { - if (!map || destroyed) return; - var b = map.getBounds(); - var bbox = [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].join(','); + // noiseFillOpacityFor mirrors fillOpacityFor for the noise tier (#a11y). + function noiseFillOpacityFor(p) { + switch (noiseColorVar(p)) { + case '--nq-cov-strong': return 0.6; + case '--nq-cov-mid': return 0.48; + case '--nq-cov-weak': return 0.34; + default: return 0.22; + } + } + + // noiseCellHtml renders the noise-layer tooltip: sample count, median, and + // the quietest/noisiest single sample in the cell. + function noiseCellHtml(p) { + if (!p) return ''; + return '
' + p.count + (p.count === 1 ? ' sample' : ' samples') + '
' + + '
' + + '
median: ' + Number(p.median_noise_floor).toFixed(1) + ' dBm
' + + '
quietest: ' + p.quietest_noise_floor + ' dBm · noisiest: ' + p.noisiest_noise_floor + ' dBm
' + + '
'; + } + + function drawSignalLayer(bbox) { var url = '/api/rx-coverage?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : ''); fetch(url).then(function (r) { return r.json(); }).then(function (fc) { - if (destroyed || !covLayer) return; + if (destroyed || !covLayer || layer !== 'signal') return; covLayer.clearLayers(); + setNoiseEmpty(false); (fc.features || []).forEach(function (f) { var ring = (f.geometry.coordinates[0] || []).map(function (c) { return [c[1], c[0]]; }); var col = cssColor(colorVar(f.properties)); L.polygon(ring, { color: col, weight: 1, fillColor: col, fillOpacity: fillOpacityFor(f.properties) }).addTo(covLayer) .bindTooltip(coverageNodesHtml(f.properties)); }); - }).catch(function (e) { console.warn('rx-coverage: coverage fetch failed', e); }); + }).catch(function (e) { + console.warn('rx-coverage: coverage fetch failed', e); + // #1: never leave stale hexes from a previous layer/view on screen. + if (!destroyed && covLayer && layer === 'signal') covLayer.clearLayers(); + }); + } + + function drawNoiseLayer(bbox) { + var url = '/api/rf-noise?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days; + fetch(url).then(function (r) { return r.json(); }).then(function (fc) { + if (destroyed || !covLayer || layer !== 'noise') return; + covLayer.clearLayers(); + var features = fc.features || []; + setNoiseEmpty(features.length === 0); + features.forEach(function (f) { + var ring = (f.geometry.coordinates[0] || []).map(function (c) { return [c[1], c[0]]; }); + var col = cssColor(noiseColorVar(f.properties)); + L.polygon(ring, { color: col, weight: 1, fillColor: col, fillOpacity: noiseFillOpacityFor(f.properties) }).addTo(covLayer) + .bindTooltip(noiseCellHtml(f.properties)); + }); + }).catch(function (e) { + console.warn('rx-coverage: noise fetch failed', e); + // #1/#2: a slow/failing request must not leave the map showing the + // other layer's hexes under the noise legend, nor sit there blank and + // unlabelled indistinguishable from "no samples in this view". + if (destroyed || !covLayer || layer !== 'noise') return; + covLayer.clearLayers(); + setNoiseEmpty(true, 'Could not load noise samples. Pan or zoom to retry.'); + }); + } + + // setNoiseEmpty toggles the "no samples in this view" message. Only shown + // on the noise layer — MC_CLIENT_RF_SAMPLES already keeps the toggle (and + // so this layer) out of reach entirely when the feature is off, so seeing + // this message always means "on", never "feature disabled" (the two states + // this project keeps needing to keep distinct). `text` overrides the + // default no-samples copy for the fetch-failed case (#2), which must read + // as distinctly different — and retryable — from "no samples here yet". + function setNoiseEmpty(show, text) { + var el = document.getElementById('rxNoiseEmpty'); + if (!el) return; + if (show && layer === 'noise') { + el.textContent = text || 'No RF samples in this view yet.'; + el.style.display = 'block'; + } else { + el.style.display = 'none'; + } + } + + function drawCoverage() { + if (!map || destroyed) return; + var b = map.getBounds(); + var bbox = [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].join(','); + if (layer === 'noise') drawNoiseLayer(bbox); + else drawSignalLayer(bbox); } // Leaderboard sort state. Default = frontier score, descending. The rank (#) @@ -214,8 +344,27 @@ loadBoard(); drawCoverage(); syncHash(); } + // setLayer switches between the 'signal' (SNR) and 'noise' hex layers. Only + // reachable when MC_CLIENT_RF_SAMPLES rendered the toggle in the first place. + function setLayer(l) { + if (l === layer) return; + layer = l; + // Clear the old layer's polygons synchronously (#1): the legend swap + // below is synchronous too, so a slow/failed fetch must never leave the + // previous layer's hexes on screen under the new layer's legend/tooltips. + if (covLayer) covLayer.clearLayers(); + var bar = document.getElementById('rxLayerBar'); + if (bar) bar.querySelectorAll('button').forEach(function (b) { b.classList.toggle('active', b.dataset.layer === l); }); + var legend = document.getElementById('rxLegend'); + if (legend) legend.outerHTML = legendHtml(); + var subtitle = document.getElementById('rxSubtitle'); + if (subtitle) subtitle.innerHTML = subtitleHtml(); + setNoiseEmpty(false); + drawCoverage(); syncHash(); + } + function syncHash() { - var q = 'days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : ''); + var q = 'days=' + days + (selectedRx ? '&rx=' + encodeURIComponent(selectedRx) : '') + (layer !== 'signal' ? '&layer=' + layer : ''); if (map) { var c = map.getCenter(); q += '&lat=' + c.lat.toFixed(5) + '&lon=' + c.lng.toFixed(5) + '&zoom=' + map.getZoom(); @@ -239,10 +388,14 @@ container.innerHTML = '
Coverage is not enabled on this deployment.
'; return; } - selectedRx = ''; selectedName = ''; days = 7; boardCache = []; + selectedRx = ''; selectedName = ''; days = 7; boardCache = []; layer = 'signal'; try { var p = (typeof getHashParams === 'function') ? getHashParams() : null; - if (p) { var dd = parseInt(p.get('days'), 10); if ([1, 7, 14, 30].indexOf(dd) >= 0) days = dd; selectedRx = (p.get('rx') || '').toLowerCase(); } + if (p) { + var dd = parseInt(p.get('days'), 10); if ([1, 7, 14, 30].indexOf(dd) >= 0) days = dd; + selectedRx = (p.get('rx') || '').toLowerCase(); + if (window.MC_CLIENT_RF_SAMPLES && p.get('layer') === 'noise') layer = 'noise'; + } } catch (e) {} container.innerHTML = pageHtml(); // Initialize viewport: explicit URL hash, coverage page's saved position, deployment defaults (#2032). @@ -282,6 +435,8 @@ }, 200)); var bar = document.getElementById('rxDays'); if (bar) bar.addEventListener('click', function (e) { var b = e.target.closest('button[data-days]'); if (b) setDays(+b.dataset.days); }); + var layerBar = document.getElementById('rxLayerBar'); + if (layerBar) layerBar.addEventListener('click', function (e) { var b = e.target.closest('button[data-layer]'); if (b) setLayer(b.dataset.layer); }); setTimeout(function () { if (!destroyed && current === generation && map) { map.invalidateSize(); if (selectedRx && !explicitViewport) fitToObserver(); else drawCoverage(); } }, 150); loadBoard(); } diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index 37c048e8..079c3ca6 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -107,6 +107,7 @@ "tests/e2e/test-packets-scope-column.js", "tests/e2e/test-path-inspector-coverage-e2e.js", "tests/e2e/test-rx-coverage-mobile-nav-e2e.js", + "tests/e2e/test-rx-coverage-noise-e2e.js", "tests/e2e/test-rx-coverage-viewport-e2e.js", "tests/e2e/test-show-neighbors.js", "tests/e2e/test-slideover-1056-e2e.js", diff --git a/test-all.sh b/test-all.sh index ff29fa74..8b49686f 100755 --- a/test-all.sh +++ b/test-all.sh @@ -181,6 +181,7 @@ node tests/unit/test-pull-to-reconnect.js node tests/unit/test-repeater-metric-scatter.js node tests/unit/test-rx-coverage-config-race.js node tests/unit/test-rx-coverage-escape.js +node tests/unit/test-rx-coverage-noise.js node tests/unit/test-rx-coverage-viewport.js node tests/unit/test-scope-audit-styles-linked.js node tests/unit/test-slideover-1056-rowsel-strict.js diff --git a/tests/e2e/test-rx-coverage-noise-e2e.js b/tests/e2e/test-rx-coverage-noise-e2e.js new file mode 100644 index 00000000..e0a02a53 --- /dev/null +++ b/tests/e2e/test-rx-coverage-noise-e2e.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node +'use strict'; +// RF noise-floor layer on the Mobile RX coverage page: the Signal/Noise toggle +// exists only when clientRfSamples is on, switching draws /api/rf-noise cells +// with the noise legend, the layer is deep-linkable, and an empty answer says +// so instead of leaving a blank map. +const assert = require('assert'); +const { chromium } = require('playwright'); +const BASE = process.env.BASE_URL || 'http://localhost:3000'; + +const cell = (lon, lat, median) => ({ + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[lon, lat], [lon + 0.01, lat], [lon + 0.01, lat + 0.01], [lon, lat + 0.01], [lon, lat]]] }, + properties: { cell: 'c' + median, count: 3, median_noise_floor: median, quietest_noise_floor: median - 2, noisiest_noise_floor: median + 2 }, +}); + +let passed = 0, failed = 0; +async function step(name, fn) { + try { await fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.log(' ✗ ' + name + ': ' + e.message); } +} + +async function openPage(browser, { rfSamples, noiseFeatures, hash }) { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + const noiseRequests = []; + await page.route('**/api/config/client', async route => { + const response = await route.fetch(); + await route.fulfill({ json: { ...await response.json(), clientRxCoverage: true, clientRfSamples: rfSamples } }); + }); + await page.route('**/api/config/map', route => route.fulfill({ json: { center: [51, 4], zoom: 9 } })); + await page.route('**/api/rx-leaderboard?*', route => route.fulfill({ json: { observers: [] } })); + await page.route('**/api/rx-coverage?*', route => route.fulfill({ json: { type: 'FeatureCollection', features: [] } })); + await page.route('**/api/rf-noise?*', route => { + noiseRequests.push(route.request().url()); + return route.fulfill({ json: { type: 'FeatureCollection', features: noiseFeatures, truncated: false } }); + }); + await page.goto(BASE + '/#/rx-coverage' + (hash || '')); + await page.waitForSelector('#rxMap.leaflet-container', { timeout: 15000 }); + return { page, errors, noiseRequests }; +} + +(async () => { + const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH || undefined }); + console.log('\n=== RF noise layer E2E against ' + BASE + ' ==='); + try { + await step('no Signal/Noise toggle when clientRfSamples is off', async () => { + const { page, errors } = await openPage(browser, { rfSamples: false, noiseFeatures: [] }); + assert.strictEqual(await page.locator('#rxLayerBar').count(), 0, 'toggle must not render when the feature is off'); + assert.deepStrictEqual(errors, []); + await page.close(); + }); + + await step('Noise draws /api/rf-noise cells with the noise legend and deep link', async () => { + const features = [cell(4.40, 51.20, -118), cell(4.50, 51.20, -111), cell(4.60, 51.20, -100)]; + const { page, errors, noiseRequests } = await openPage(browser, { rfSamples: true, noiseFeatures: features }); + await page.waitForSelector('#rxLayerBar button[data-layer="noise"]'); + await page.click('#rxLayerBar button[data-layer="noise"]'); + await page.waitForFunction(() => document.querySelectorAll('#rxMap path.leaflet-interactive').length === 3, null, { timeout: 10000 }); + assert.ok(noiseRequests.length >= 1, 'the noise layer must fetch /api/rf-noise'); + const q = new URL(noiseRequests[noiseRequests.length - 1]).searchParams; + assert.ok(q.get('bbox') && q.get('z') && q.get('days'), 'bbox, z and days are sent: ' + q.toString()); + const legend = await page.locator('#rxLegend').innerText(); + assert.ok(/quiet/.test(legend) && /busy/.test(legend), 'legend switches to the noise tiers: ' + legend); + assert.ok(/noise floor/i.test(await page.locator('#rxSubtitle').innerText()), 'subtitle names the noise floor'); + assert.ok(/[?&]layer=noise/.test(await page.evaluate(() => location.hash)), 'layer is in the URL hash'); + const pressed = await page.locator('#rxLayerBar button[data-layer="noise"]').getAttribute('class'); + assert.ok(/active/.test(pressed || ''), 'Noise button is marked active'); + assert.ok(!(await page.locator('#rxNoiseEmpty').isVisible()), 'no empty-state message when cells exist'); + assert.deepStrictEqual(errors, []); + await page.close(); + }); + + await step('a deep link opens on the noise layer, and an empty answer is labelled', async () => { + const { page, errors, noiseRequests } = await openPage(browser, { rfSamples: true, noiseFeatures: [], hash: '?layer=noise' }); + await page.waitForFunction(() => { + const el = document.getElementById('rxNoiseEmpty'); + return el && getComputedStyle(el).display !== 'none'; + }, null, { timeout: 10000 }); + assert.ok(noiseRequests.length >= 1, 'a ?layer=noise link must fetch the noise layer without a click'); + assert.ok(/No RF samples/.test(await page.locator('#rxNoiseEmpty').innerText())); + assert.deepStrictEqual(errors, []); + await page.close(); + }); + } finally { + await browser.close(); + } + console.log(`\ntest-rx-coverage-noise-e2e: ${passed} passed, ${failed} failed`); + process.exit(failed ? 1 : 0); +})(); diff --git a/tests/unit/test-rx-coverage-noise.js b/tests/unit/test-rx-coverage-noise.js new file mode 100644 index 00000000..b38e38ae --- /dev/null +++ b/tests/unit/test-rx-coverage-noise.js @@ -0,0 +1,56 @@ +'use strict'; +const REPO_ROOT = require('path').resolve(__dirname, '..', '..'); +// The RF noise layer colours a hex by its median noise floor. The axis runs the +// opposite way to the SNR layer: a LOWER (more negative) dBm is quieter, so it +// must map to the "strong" (green) token. Getting the comparison direction +// wrong paints the quietest cells red, which is the mistake this pins. +// +// Like test-rx-coverage-escape.js, the real thresholds and noiseColorVar are +// sliced out of public/rx-coverage.js and run in a vm sandbox, so the test +// tracks the source rather than a copy. +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const src = fs.readFileSync(path.join(REPO_ROOT, 'public', 'rx-coverage.js'), 'utf8'); + +const startMarker = 'var NOISE_QUIET_MAX'; +const fnMarker = 'function noiseColorVar(p) {'; +const startIdx = src.indexOf(startMarker); +assert.ok(startIdx >= 0, 'could not locate NOISE_QUIET_MAX in rx-coverage.js'); +const fnIdx = src.indexOf(fnMarker, startIdx); +assert.ok(fnIdx >= 0, 'could not locate noiseColorVar in rx-coverage.js'); +const endIdx = src.indexOf('\n }\n', fnIdx); +assert.ok(endIdx >= 0, 'could not locate the end of noiseColorVar'); +const block = src.slice(startIdx, endIdx + 4); + +const sandbox = {}; +vm.createContext(sandbox); +vm.runInContext(block + '\nthis.noiseColorVar = noiseColorVar; this.QUIET = NOISE_QUIET_MAX; this.BUSY = NOISE_BUSY_MIN;', sandbox); +const colour = (dbm) => sandbox.noiseColorVar({ median_noise_floor: dbm }); + +let passed = 0; +function check(name, fn) { fn(); passed++; console.log(' ✓ ' + name); } + +check('thresholds are ordered quiet < busy', () => { + assert.ok(sandbox.QUIET < sandbox.BUSY, `quiet max ${sandbox.QUIET} must be below busy min ${sandbox.BUSY}`); +}); +check('a quiet floor is the strong (green) token', () => { + assert.strictEqual(colour(-125), '--nq-cov-strong'); + assert.strictEqual(colour(sandbox.QUIET), '--nq-cov-strong', 'the quiet bound itself is quiet'); +}); +check('between the bounds is mid', () => { + assert.strictEqual(colour(sandbox.QUIET + 1), '--nq-cov-mid'); + assert.strictEqual(colour(sandbox.BUSY), '--nq-cov-mid', 'the busy bound itself is still mid'); +}); +check('a busy floor is the weak (red) token', () => { + assert.strictEqual(colour(sandbox.BUSY + 1), '--nq-cov-weak'); + assert.strictEqual(colour(-80), '--nq-cov-weak'); +}); +check('a string median from the API is cast, not compared as text', () => { + assert.strictEqual(colour('-120'), '--nq-cov-strong'); + assert.strictEqual(colour('-90'), '--nq-cov-weak'); +}); + +console.log(`rx-coverage noise colours: ${passed} passed, 0 failed`);