diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4a364be26..fe116305c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -678,6 +678,7 @@ jobs: suite 2 test-neighbor-map-btn-clip-e2e.js CHROMIUM_REQUIRE=1 suite 3 test-rx-coverage-viewport-e2e.js BASE_URL=$B suite 1 test-rx-coverage-noise-e2e.js BASE_URL=$B + suite 1 test-rx-coverage-uplink-e2e.js BASE_URL=$B # 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 acc3315bc..dd0c9181b 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -185,6 +185,10 @@ type Config struct { // off; see ClientRfSamplesEnabled. ClientRfSamples *ClientRfSamplesConfig `json:"clientRfSamples,omitempty"` + // ClientUplink gates the uplink ping layer (cmd/server/uplink_coverage.go). + // Same JSON section as the ingestor's ClientUplinkConfig; absent ⇒ off. + ClientUplink *ClientUplinkConfig `json:"clientUplink,omitempty"` + // UserManagement gates optional accounts // (docs/specs/2026-10-06-user-management-design.md). Absent/nil means off; // see UserManagementEnabled. @@ -318,6 +322,16 @@ type ClientRfSamplesConfig struct { Enabled bool `json:"enabled"` } +// ClientUplinkConfig mirrors cmd/ingestor's ClientUplinkConfig. +type ClientUplinkConfig struct { + Enabled bool `json:"enabled"` +} + +// ClientUplinkEnabled reports whether the uplink layer is served. +func (c *Config) ClientUplinkEnabled() bool { + return c != nil && c.ClientUplink != nil && c.ClientUplink.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. diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index 3ae41e277..259f6adf9 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -160,6 +160,12 @@ func routeDescriptions() map[string]routeMeta { {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/uplink-coverage": {Summary: "Uplink ping hex grid", Description: "GeoJSON hex cells where mobile companions sent uplink pings (CoreDrive RX, client_uplink_pings). Per cell: pings, and how many were `both` (a repeater re-broadcast was heard by the companion AND an observer received the ping), `observed` (observer only), `echo` (re-broadcast heard, no observer received it) or `none`; `best` is the best class in the cell, plus the first-hop repeaters, the most observers reached and the longest path. `observed` depends on observer placement: a ping no observer received may still have travelled. Gated on clientUplink.", 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/rx-coverage": {Summary: "Mobile RX coverage hex grid", Description: "GeoJSON hex cells where mobile clients directly heard nodes (client_receptions): count, best SNR and the nodes heard per cell. With gaps=1 the payload also carries a `gaps` member: cells a companion drove through (moving client_rf_samples) with no reception in the same window, each with its sample count, plus `gaps_truncated` when capped. Gaps are only returned without a node filter and with clientRfSamples on; they mean nothing was received in this period, not that there is no coverage. Gated on clientRxCoverage.", Tag: "coverage", QueryParams: []paramMeta{ {Name: "bbox", Description: "Bounding box as minLat,minLon,maxLat,maxLon", Type: "string", Required: true}, diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 6805c41d0..d852da54f 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -348,6 +348,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) { // 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/uplink-coverage", s.handleUplinkCoverage).Methods("GET") // gated by requireClientUplink 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") @@ -550,6 +551,7 @@ func (s *Server) handleConfigClient(w http.ResponseWriter, r *http.Request) { Customizer: CustomizerClientConfig{DisabledTabs: disabledTabs}, ClientRxCoverage: s.cfg.ClientRxCoverageEnabled(), ClientRfSamples: s.cfg.ClientRfSamplesEnabled(), + ClientUplink: s.cfg.ClientUplinkEnabled(), ClientRxRequireLinkedCompanion: s.auth != nil && s.cfg.ClientRxRequireLinkedCompanionSet(), PathTrust: &pathTrust, UserManagement: s.clientUserManagement(), diff --git a/cmd/server/types.go b/cmd/server/types.go index 08acf1b92..8a4bc1e2a 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -1086,6 +1086,7 @@ type ClientConfigResponse struct { Customizer CustomizerClientConfig `json:"customizer"` ClientRxCoverage bool `json:"clientRxCoverage"` ClientRfSamples bool `json:"clientRfSamples"` + ClientUplink bool `json:"clientUplink"` // Present (true) only when clientRxCoverage.requireLinkedCompanion is set // and user management is on: CoreDrive RX then holds its queue until // its companion is linked. diff --git a/cmd/server/uplink_coverage.go b/cmd/server/uplink_coverage.go new file mode 100644 index 000000000..2320699c4 --- /dev/null +++ b/cmd/server/uplink_coverage.go @@ -0,0 +1,292 @@ +package main + +import ( + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +// Uplink coverage: per hex cell, whether the mesh heard the mobile companions +// that pinged there. A ping is one "cdrx " channel message from CoreDrive +// RX (cmd/ingestor/client_uplink.go stores it). Two facts decide its class: +// +// echo the companion heard a repeater re-broadcast the ping +// observed a CoreScope observer received the ping (by its packet hash) +// +// "observed" depends on where observers stand: a ping no observer received +// may still have travelled. The legend says so; the data cannot. + +const ( + uplinkBoth = "both" // echo and observed + uplinkObserved = "observed" // observed, no echo heard + uplinkEcho = "echo" // echo, no observer received it + uplinkNone = "none" // neither +) + +// uplinkRank orders classes from best to worst for a cell's summary colour. +var uplinkRank = map[string]int{uplinkBoth: 3, uplinkObserved: 2, uplinkEcho: 1, uplinkNone: 0} + +// uplinkFeatureCap bounds the response like the other coverage layers. +const uplinkFeatureCap = 5000 + +// uplinkMaxFirstHops bounds the first-hop list per cell. +const uplinkMaxFirstHops = 12 + +type uplinkPingRow struct { + Lat, Lon float64 + SentAt string + EchoCount int + FirstHops string // comma-separated, from the ingestor + PktHash string // from the echo, else from client_uplink_tx; "" when unknown +} + +type uplinkArrival struct { + Observers int + MaxHops int +} + +func uplinkClass(echo, observed bool) string { + switch { + case echo && observed: + return uplinkBoth + case observed: + return uplinkObserved + case echo: + return uplinkEcho + } + return uplinkNone +} + +type UplinkFeatureCollection struct { + Type string `json:"type"` // "FeatureCollection" + Features []UplinkFeature `json:"features"` + Truncated bool `json:"truncated,omitempty"` +} + +type UplinkFeature struct { + Type string `json:"type"` // "Feature" + Geometry CoveragePolygon `json:"geometry"` + Properties UplinkProperties `json:"properties"` +} + +// UplinkProperties summarises the pings in one cell. +type UplinkProperties struct { + Cell string `json:"cell"` + Pings int `json:"pings"` + Both int `json:"both"` + Observed int `json:"observed"` + Echo int `json:"echo"` + None int `json:"none"` + Best string `json:"best"` + FirstHops []string `json:"first_hops"` + MaxObservers int `json:"max_observers"` + MaxHops int `json:"max_hops"` + LastAt string `json:"last_at"` +} + +func aggregateUplink(rows []uplinkPingRow, arrivals map[string]uplinkArrival, res int) UplinkFeatureCollection { + type agg struct { + p UplinkProperties + hops map[string]bool + } + byCell := map[string]*agg{} + for _, r := range rows { + cell := hexCellAt(r.Lat, r.Lon, res) + a := byCell[cell] + if a == nil { + a = &agg{p: UplinkProperties{Cell: cell, Best: uplinkNone, FirstHops: []string{}}, hops: map[string]bool{}} + byCell[cell] = a + } + arr := arrivals[r.PktHash] + class := uplinkClass(r.EchoCount > 0, r.PktHash != "" && arr.Observers > 0) + a.p.Pings++ + switch class { + case uplinkBoth: + a.p.Both++ + case uplinkObserved: + a.p.Observed++ + case uplinkEcho: + a.p.Echo++ + default: + a.p.None++ + } + if uplinkRank[class] > uplinkRank[a.p.Best] { + a.p.Best = class + } + if arr.Observers > a.p.MaxObservers { + a.p.MaxObservers = arr.Observers + } + if arr.MaxHops > a.p.MaxHops { + a.p.MaxHops = arr.MaxHops + } + if r.SentAt > a.p.LastAt { + a.p.LastAt = r.SentAt + } + for _, h := range strings.Split(r.FirstHops, ",") { + if h != "" && !a.hops[h] && len(a.p.FirstHops) < uplinkMaxFirstHops { + a.hops[h] = true + a.p.FirstHops = append(a.p.FirstHops, h) + } + } + } + fc := UplinkFeatureCollection{Type: "FeatureCollection", Features: []UplinkFeature{}} + for cell, a := range byCell { + ring := hexBoundary(cell) + if ring == nil { + continue + } + fc.Features = append(fc.Features, UplinkFeature{ + Type: "Feature", + Geometry: CoveragePolygon{Type: "Polygon", Coordinates: [][][2]float64{ring}}, + Properties: a.p, + }) + } + if len(fc.Features) > uplinkFeatureCap { + sort.Slice(fc.Features, func(i, j int) bool { + pi, pj := fc.Features[i].Properties.Pings, fc.Features[j].Properties.Pings + if pi != pj { + return pi > pj + } + return fc.Features[i].Properties.Cell < fc.Features[j].Properties.Cell + }) + fc.Features = fc.Features[:uplinkFeatureCap] + fc.Truncated = true + } + sort.Slice(fc.Features, func(i, j int) bool { return fc.Features[i].Properties.Cell < fc.Features[j].Properties.Cell }) + return fc +} + +// queryUplinkPings returns pings with a position inside the bbox, within +// `days` (0 = all time). The packet hash comes from the ping when it had an +// echo, otherwise from the ingestor's own tag -> hash link. +func (s *Server) queryUplinkPings(days int, b bbox) ([]uplinkPingRow, error) { + where := []string{"p.lat IS NOT NULL", "p.lat BETWEEN ? AND ?", "p.lon BETWEEN ? AND ?"} + args := []interface{}{b.MinLat, b.MaxLat, b.MinLon, b.MaxLon} + if days > 0 { + where = append(where, "p.sent_at >= ?") + args = append(args, time.Now().UTC().AddDate(0, 0, -days).Format(rxTimeMillisLayoutServer)) + } + rows, err := s.db.conn.Query(` + SELECT p.lat, p.lon, p.sent_at, p.echo_count, p.first_hops, COALESCE(p.pkt_hash, t.pkt_hash, '') + FROM client_uplink_pings p + LEFT JOIN client_uplink_tx t ON t.tag = p.tag + WHERE `+strings.Join(where, " AND "), args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []uplinkPingRow{} + for rows.Next() { + var r uplinkPingRow + if err := rows.Scan(&r.Lat, &r.Lon, &r.SentAt, &r.EchoCount, &r.FirstHops, &r.PktHash); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// queryUplinkArrivals counts, per packet hash, the distinct observers that +// received it and the longest path they saw. One query per 500 hashes. +func (s *Server) queryUplinkArrivals(rows []uplinkPingRow) (map[string]uplinkArrival, error) { + seen := map[string]bool{} + var hashes []string + for _, r := range rows { + if r.PktHash != "" && !seen[r.PktHash] { + seen[r.PktHash] = true + hashes = append(hashes, r.PktHash) + } + } + out := make(map[string]uplinkArrival, len(hashes)) + observerCol := "o.observer_idx" + if !s.db.isV3 { + observerCol = "o.observer_id" + } + const chunk = 500 + for i := 0; i < len(hashes); i += chunk { + end := i + chunk + if end > len(hashes) { + end = len(hashes) + } + batch := hashes[i:end] + args := make([]interface{}, len(batch)) + for j, h := range batch { + args[j] = h + } + q, err := s.db.conn.Query(` + SELECT t.hash, COUNT(DISTINCT `+observerCol+`), MAX(json_array_length(o.path_json)) + FROM transmissions t JOIN observations o ON o.transmission_id = t.id + WHERE t.hash IN (`+sqlPlaceholders(len(batch))+`) + GROUP BY t.hash`, args...) + if err != nil { + return nil, err + } + for q.Next() { + var h string + var a uplinkArrival + var hops *int + if err := q.Scan(&h, &a.Observers, &hops); err != nil { + q.Close() + return nil, err + } + if hops != nil { + a.MaxHops = *hops + } + out[h] = a + } + q.Close() + } + return out, nil +} + +// rxTimeMillisLayoutServer matches the ingestor's sent_at format +// (rxTimeMillisLayout in cmd/ingestor), so the cutoff compares +// lexicographically against stored values. +const rxTimeMillisLayoutServer = "2006-01-02T15:04:05.000Z07:00" + +func (s *Server) requireClientUplink(w http.ResponseWriter, r *http.Request) bool { + if s == nil || s.cfg == nil || !s.cfg.ClientUplinkEnabled() { + http.NotFound(w, r) + return false + } + return true +} + +// handleUplinkCoverage serves the uplink layer as GeoJSON hex cells. An +// instance whose ingestor has not created the tables yet answers an empty +// collection. +func (s *Server) handleUplinkCoverage(w http.ResponseWriter, r *http.Request) { + if !s.requireClientUplink(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")) + fc := UplinkFeatureCollection{Type: "FeatureCollection", Features: []UplinkFeature{}} + if s.db.tablePresent("client_uplink_pings") && s.db.tablePresent("client_uplink_tx") { + rows, err := s.queryUplinkPings(days, b) + if err != nil { + http.Error(w, "query failed", http.StatusInternalServerError) + return + } + arrivals, err := s.queryUplinkArrivals(rows) + if err != nil { + http.Error(w, "query failed", http.StatusInternalServerError) + return + } + fc = aggregateUplink(rows, arrivals, zoomToHexRes(z)) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(fc) +} diff --git a/cmd/server/uplink_coverage_test.go b/cmd/server/uplink_coverage_test.go new file mode 100644 index 000000000..0af6ed670 --- /dev/null +++ b/cmd/server/uplink_coverage_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" +) + +func TestUplinkClass(t *testing.T) { + cases := map[[2]bool]string{ + {true, true}: uplinkBoth, {false, true}: uplinkObserved, {true, false}: uplinkEcho, {false, false}: uplinkNone, + } + for in, want := range cases { + if got := uplinkClass(in[0], in[1]); got != want { + t.Errorf("uplinkClass(echo=%v, observed=%v) = %s, want %s", in[0], in[1], got, want) + } + } +} + +func TestAggregateUplinkCountsClassesPerCell(t *testing.T) { + rows := []uplinkPingRow{ + {Lat: 50.998, Lon: 5.384, SentAt: "2026-10-16T14:51:15.018Z", EchoCount: 3, FirstHops: "c0cd,1403", PktHash: "aaaa"}, + {Lat: 50.998, Lon: 5.384, SentAt: "2026-10-16T14:02:45.000Z", EchoCount: 0, FirstHops: "", PktHash: ""}, + {Lat: 50.998, Lon: 5.3841, SentAt: "2026-10-16T15:00:00.000Z", EchoCount: 1, FirstHops: "1403", PktHash: "bbbb"}, + {Lat: 51.2, Lon: 5.6, SentAt: "2026-10-16T15:10:00.000Z", EchoCount: 0, FirstHops: "", PktHash: "cccc"}, + } + arrivals := map[string]uplinkArrival{"aaaa": {Observers: 17, MaxHops: 11}, "cccc": {Observers: 2, MaxHops: 3}} + fc := aggregateUplink(rows, arrivals, 9) + if len(fc.Features) != 2 { + t.Fatalf("cells = %d, want 2", len(fc.Features)) + } + byCell := map[string]UplinkProperties{} + for _, f := range fc.Features { + byCell[f.Properties.Cell] = f.Properties + } + home := byCell[hexCellAt(50.998, 5.384, 9)] + if home.Pings != 3 || home.Both != 1 || home.Echo != 1 || home.None != 1 || home.Observed != 0 { + t.Fatalf("home cell = %+v", home) + } + if home.Best != uplinkBoth || home.MaxObservers != 17 || home.MaxHops != 11 { + t.Errorf("home summary = %+v", home) + } + if len(home.FirstHops) != 2 || home.FirstHops[0] != "c0cd" || home.FirstHops[1] != "1403" { + t.Errorf("first hops = %v, want distinct c0cd,1403", home.FirstHops) + } + if home.LastAt != "2026-10-16T15:00:00.000Z" { + t.Errorf("last_at = %s", home.LastAt) + } + far := byCell[hexCellAt(51.2, 5.6, 9)] + if far.Observed != 1 || far.Best != uplinkObserved { + t.Errorf("far cell = %+v, want observed only", far) + } +} + +func seedUplinkDB(t *testing.T) *DB { + t.Helper() + db := setupTestDBv2(t) + mustExecDB(t, db, `CREATE TABLE client_uplink_pings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, rx_pubkey TEXT NOT NULL, tag TEXT NOT NULL, sent_at TEXT NOT NULL, + ingested_at TEXT NOT NULL, channel TEXT NOT NULL DEFAULT '', scope TEXT NOT NULL DEFAULT '', pkt_hash TEXT, + echo_count INTEGER NOT NULL DEFAULT 0, first_hops TEXT NOT NULL DEFAULT '', echoes_json TEXT NOT NULL DEFAULT '[]', + lat REAL, lon REAL, pos_acc_m REAL, UNIQUE(rx_pubkey, tag))`) + mustExecDB(t, db, `CREATE TABLE client_uplink_tx (tag TEXT PRIMARY KEY, pkt_hash TEXT NOT NULL, sender TEXT NOT NULL DEFAULT '', seen_at TEXT NOT NULL)`) + recent := time.Now().UTC().Add(-time.Hour).Format(rxTimeMillisLayoutServer) + old := time.Now().UTC().AddDate(0, 0, -20).Format(rxTimeMillisLayoutServer) + // Echo plus observers: both. + mustExecDB(t, db, `INSERT INTO client_uplink_pings (rx_pubkey, tag, sent_at, ingested_at, pkt_hash, echo_count, first_hops, lat, lon) + VALUES ('aa11','78277c0e','`+recent+`','x','b76c6a2777f9b0e9',3,'1403,c0cd',50.998,5.384)`) + // No echo, hash learned by the ingestor's decrypt: observed. + mustExecDB(t, db, `INSERT INTO client_uplink_pings (rx_pubkey, tag, sent_at, ingested_at, echo_count, lat, lon) + VALUES ('aa11','11111111','`+recent+`','x',0,51.10,5.50)`) + mustExecDB(t, db, `INSERT INTO client_uplink_tx (tag, pkt_hash, seen_at) VALUES ('11111111','1111aaaa2222bbbb','x')`) + // Old ping: outside a 7-day window. + mustExecDB(t, db, `INSERT INTO client_uplink_pings (rx_pubkey, tag, sent_at, ingested_at, echo_count, lat, lon) + VALUES ('aa11','22222222','`+old+`','x',0,50.90,5.30)`) + // No position: never on the map. + mustExecDB(t, db, `INSERT INTO client_uplink_pings (rx_pubkey, tag, sent_at, ingested_at, echo_count) + VALUES ('aa11','33333333','`+recent+`','x',1)`) + for _, h := range []string{"b76c6a2777f9b0e9", "1111aaaa2222bbbb"} { + mustExecDB(t, db, `INSERT INTO transmissions (raw_hex, hash, first_seen, payload_type) VALUES ('00','`+h+`','x',5)`) + } + mustExecDB(t, db, `INSERT INTO observations (transmission_id, observer_id, path_json, timestamp) VALUES + (1,'obsA','["1403","23C6"]',1),(1,'obsB','["1403","23C6","907C"]',1),(1,'obsA','["1403"]',1), + (2,'obsC','["5A5A"]',1)`) + return db +} + +func getUplink(t *testing.T, srv *Server, q string) (int, UplinkFeatureCollection) { + t.Helper() + router := mux.NewRouter() + router.HandleFunc("/api/uplink-coverage", srv.handleUplinkCoverage).Methods("GET") + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest("GET", "/api/uplink-coverage?"+q, nil)) + var fc UplinkFeatureCollection + if rr.Code == 200 { + if err := json.Unmarshal(rr.Body.Bytes(), &fc); err != nil { + t.Fatal(err) + } + } + return rr.Code, fc +} + +func TestUplinkCoverageEndpoint(t *testing.T) { + srv := &Server{db: seedUplinkDB(t), cfg: &Config{ClientUplink: &ClientUplinkConfig{Enabled: true}}} + code, fc := getUplink(t, srv, "bbox=50,5,52,6&z=12&days=7") + if code != 200 { + t.Fatalf("status %d", code) + } + if len(fc.Features) != 2 { + t.Fatalf("cells = %d, want 2 (old and position-less pings left out)", len(fc.Features)) + } + got := map[string]UplinkProperties{} + for _, f := range fc.Features { + got[f.Properties.Best] = f.Properties + } + both, ok := got[uplinkBoth] + if !ok || both.MaxObservers != 2 || both.MaxHops != 3 { + t.Fatalf("both cell = %+v, want 2 distinct observers and 3 hops", both) + } + if obs, ok := got[uplinkObserved]; !ok || obs.MaxObservers != 1 { + t.Fatalf("observed cell (hash from client_uplink_tx) = %+v", obs) + } + if code, fc := getUplink(t, srv, "bbox=50,5,52,6&z=12&days=30"); code != 200 || len(fc.Features) != 3 { + t.Fatalf("30 days: status %d, cells %d, want 3", code, len(fc.Features)) + } +} + +func TestUplinkCoverageGatedAndTolerant(t *testing.T) { + off := &Server{db: seedUplinkDB(t), cfg: &Config{}} + if code, _ := getUplink(t, off, "bbox=50,5,52,6"); code != 404 { + t.Fatalf("feature off: status %d, want 404", code) + } + on := &Server{db: seedUplinkDB(t), cfg: &Config{ClientUplink: &ClientUplinkConfig{Enabled: true}}} + if code, _ := getUplink(t, on, "bbox=nope"); code != 400 { + t.Fatalf("bad bbox: status %d, want 400", code) + } + bare := &Server{db: setupTestDBv2(t), cfg: &Config{ClientUplink: &ClientUplinkConfig{Enabled: true}}} + if code, fc := getUplink(t, bare, "bbox=50,5,52,6"); code != 200 || len(fc.Features) != 0 { + t.Fatalf("no tables yet: status %d, cells %d, want 200 and none", code, len(fc.Features)) + } +} diff --git a/docs/client-rx-coverage.md b/docs/client-rx-coverage.md index 92a314e8c..8856d0ec9 100644 --- a/docs/client-rx-coverage.md +++ b/docs/client-rx-coverage.md @@ -236,6 +236,12 @@ listens 10 s for repeaters re-broadcasting it, and publishes the ping here. Acce - Echoes keep their first hop (the repeater that heard the companion) and last hop (the one the companion heard it from). Invalid echoes are dropped; at most 32 are kept. - The broker ACL must allow `/uplink`. +- **On the map:** the RX coverage page gets an **Uplink** layer (`GET /api/uplink-coverage`) when + `clientUplink.enabled` is set. Each hex shows the best result of the pings sent there: + *both directions* (a repeater re-broadcast was heard and an observer received the ping), + *heard by observers*, *repeater only* (no observer received it), or *not heard*. The tooltip lists + the repeaters that picked a ping up (first hops), the most observers reached and the longest path. + "Not received by an observer" can also mean no observer is in range there. ## Capture HARD RULE — only what was heard directly diff --git a/public/node-reach-coverage.css b/public/node-reach-coverage.css index dec1692b8..e93a25af2 100644 --- a/public/node-reach-coverage.css +++ b/public/node-reach-coverage.css @@ -11,6 +11,11 @@ --nq-cov-weak: #e74c3c; /* SF8: < −9 dB (poor, packet loss likely) */ --nq-cov-grey: #95a5a6; /* heard, no SNR metric */ --nq-cov-gap: #57606a; /* hatch: driven through, nothing received */ + /* Uplink layer (rx-coverage.js drawUplinkLayer): did the mesh hear the companion */ + --nq-up-both: #2ecc71; /* a repeater echoed it AND an observer received it */ + --nq-up-observed: #2f80ed; /* an observer received it, no echo heard */ + --nq-up-echo: #e6a817; /* a repeater echoed it, no observer received it */ + --nq-up-none: #c0392b; /* neither */ } /* Dark-theme variants: the saturated mid-luminance defaults glare on dark basemaps. Mirrors the rest of the dashboard's theme-aware tokens (#polish). */ @@ -20,6 +25,10 @@ --nq-cov-weak: #f85149; --nq-cov-grey: #8b949e; --nq-cov-gap: #c9d1d9; + --nq-up-both: #3fb950; + --nq-up-observed: #58a6ff; + --nq-up-echo: #d29922; + --nq-up-none: #f85149; } .nq-cov-legend { display:flex; gap:12px; align-items:center; font-size:11px; color:var(--text-muted, #57606a); margin:4px 0 10px; } /* Toggled by node-reach.js applyCoverage via class, not inline style, so CSS diff --git a/public/roles.js b/public/roles.js index e976e12df..c3f2f27af 100644 --- a/public/roles.js +++ b/public/roles.js @@ -593,6 +593,7 @@ 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; + window.MC_CLIENT_UPLINK = cfg.clientUplink === 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 39c450ab4..d8265f69e 100644 --- a/public/rx-coverage.js +++ b/public/rx-coverage.js @@ -7,8 +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). + // layer: 'signal' (default, per-cell best SNR of directly-heard nodes), + // 'noise' (RF noise-floor layer, opt-in — see MC_CLIENT_RF_SAMPLES) or + // 'uplink' (did the mesh hear the companion's pings — see MC_CLIENT_UPLINK). var layer = 'signal'; // showGaps: on the signal layer, also draw cells a companion drove through // with nothing received (/api/rx-coverage?gaps=1). Needs the RF sample @@ -93,7 +94,39 @@ return '--nq-cov-weak'; } + // Uplink classes, best first: the legend order and the colour of a cell's best ping. + var UPLINK_CLASSES = [ + { key: 'both', label: 'both directions', title: 'A repeater re-broadcast the ping where the companion heard it, and an observer received it' }, + { key: 'observed', label: 'heard by observers', title: 'An observer received the ping; the companion heard no re-broadcast' }, + { key: 'echo', label: 'repeater only', title: 'A repeater re-broadcast the ping, but no observer received it. Where no observer is in range this is the best a ping can show.' }, + { key: 'none', label: 'not heard', title: 'No re-broadcast heard and no observer received it' } + ]; + + function uplinkColorVar(p) { + var k = p && p.best; + return UPLINK_CLASSES.some(function (c) { return c.key === k; }) ? '--nq-up-' + k : '--nq-up-none'; + } + + function uplinkCellHtml(p) { + if (!p) return ''; + var parts = UPLINK_CLASSES.filter(function (c) { return p[c.key] > 0; }).map(function (c) { return p[c.key] + ' ' + c.label; }); + var hops = (p.first_hops || []).map(function (h) { return escapeHtml(String(h).toUpperCase()); }).join(', '); + return '
' + p.pings + (p.pings === 1 ? ' uplink ping' : ' uplink pings') + '
' + + '
' + + '
' + parts.join(' · ') + '
' + + (hops ? '
picked up by: ' + hops + '
' : '') + + (p.max_observers ? '
reached ' + p.max_observers + (p.max_observers === 1 ? ' observer' : ' observers') + (p.max_hops ? ', up to ' + p.max_hops + ' hops' : '') + '
' : '') + + '
last: ' + escapeHtml(String(p.last_at || '').slice(0, 16).replace('T', ' ')) + ' UTC
' + + '
'; + } + function legendHtml() { + if (layer === 'uplink') { + return '
' + + UPLINK_CLASSES.map(function (c) { return '' + c.label + ''; }).join('') + + '· "not heard by observers" can also mean no observer is in range' + + '
'; + } if (layer === 'noise') { return '
' + 'quiet (≤ ' + NOISE_QUIET_MAX + ' dBm)' + @@ -154,14 +187,19 @@ // must not keep claiming best-signal colouring while the noise legend (an // inverted, opposite-direction colour axis) sits right below it. function subtitleHtml() { + if (layer === 'uplink') { + return 'Where the mesh heard roaming companions: each cell shows the best result of the uplink pings sent there. Get the companion app →'; + } 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') + '
' + - '
' + gapsBtnHtml() + '
' + var layerBar = (window.MC_CLIENT_RF_SAMPLES || window.MC_CLIENT_UPLINK) + ? '
' + layerBtn('signal', 'Signal') + + (window.MC_CLIENT_RF_SAMPLES ? layerBtn('noise', 'Noise') : '') + + (window.MC_CLIENT_UPLINK ? layerBtn('uplink', 'Uplink') : '') + '
' + + (window.MC_CLIENT_RF_SAMPLES ? '
' + gapsBtnHtml() + '
' : '') : ''; return '
' + '

Mobile RX coverage

' + @@ -304,6 +342,27 @@ }); } + function drawUplinkLayer(bbox) { + var url = '/api/uplink-coverage?bbox=' + bbox + '&z=' + map.getZoom() + '&days=' + days; + fetch(url).then(function (r) { return r.json(); }).then(function (fc) { + if (destroyed || !covLayer || layer !== 'uplink') return; + covLayer.clearLayers(); + var features = fc.features || []; + setNoiseEmpty(features.length === 0, 'No uplink pings in this view yet.'); + features.forEach(function (f) { + var ring = (f.geometry.coordinates[0] || []).map(function (c) { return [c[1], c[0]]; }); + var col = cssColor(uplinkColorVar(f.properties)); + L.polygon(ring, { color: col, weight: 1, fillColor: col, fillOpacity: 0.5 }).addTo(covLayer) + .bindTooltip(uplinkCellHtml(f.properties)); + }); + }).catch(function (e) { + console.warn('rx-coverage: uplink fetch failed', e); + if (destroyed || !covLayer || layer !== 'uplink') return; + covLayer.clearLayers(); + setNoiseEmpty(true, 'Could not load uplink pings. 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 @@ -314,7 +373,7 @@ function setNoiseEmpty(show, text) { var el = document.getElementById('rxNoiseEmpty'); if (!el) return; - if (show && layer === 'noise') { + if (show && (layer === 'noise' || layer === 'uplink')) { el.textContent = text || 'No RF samples in this view yet.'; el.style.display = 'block'; } else { @@ -327,6 +386,7 @@ var b = map.getBounds(); var bbox = [b.getSouth(), b.getWest(), b.getNorth(), b.getEast()].join(','); if (layer === 'noise') drawNoiseLayer(bbox); + else if (layer === 'uplink') drawUplinkLayer(bbox); else drawSignalLayer(bbox); } @@ -517,6 +577,7 @@ 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'; + if (window.MC_CLIENT_UPLINK && p.get('layer') === 'uplink') layer = 'uplink'; if (p.get('gaps') === '0') showGaps = false; mine = p.get('mine') === '1'; } @@ -614,6 +675,6 @@ } if (window.addEventListener) window.addEventListener('cs-auth-changed', onAuthChanged); - window.CSRxCoverage = { _test: { coverageUrl: coverageUrl, mineBtnHtml: mineBtnHtml, authUser: authUser, coverageReply: coverageReply } }; + window.CSRxCoverage = { _test: { coverageUrl: coverageUrl, mineBtnHtml: mineBtnHtml, authUser: authUser, coverageReply: coverageReply, uplinkColorVar: uplinkColorVar, uplinkCellHtml: uplinkCellHtml } }; registerPage('rx-coverage', { init: init, destroy: destroy }); })(); diff --git a/scripts/non-unit-tests.json b/scripts/non-unit-tests.json index 0ae2d0353..38a4b3a14 100644 --- a/scripts/non-unit-tests.json +++ b/scripts/non-unit-tests.json @@ -112,6 +112,7 @@ "tests/e2e/test-rx-coverage-mobile-nav-e2e.js", "tests/e2e/test-rx-coverage-gaps-e2e.js", "tests/e2e/test-rx-coverage-noise-e2e.js", + "tests/e2e/test-rx-coverage-uplink-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 bbbd73d45..5bea4ccb2 100755 --- a/test-all.sh +++ b/test-all.sh @@ -203,6 +203,7 @@ node tests/unit/test-rx-coverage-config-race.js node tests/unit/test-rx-coverage-mine.js node tests/unit/test-rx-coverage-escape.js node tests/unit/test-rx-coverage-noise.js +node tests/unit/test-rx-coverage-uplink.js node tests/unit/test-rx-coverage-viewport.js node tests/unit/test-scope-audit-styles-linked.js node tests/unit/test-settings-sync.js diff --git a/tests/e2e/test-rx-coverage-uplink-e2e.js b/tests/e2e/test-rx-coverage-uplink-e2e.js new file mode 100644 index 000000000..4cf6f6b89 --- /dev/null +++ b/tests/e2e/test-rx-coverage-uplink-e2e.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +'use strict'; +// Uplink layer on the Mobile RX coverage page: the "Uplink" button exists only +// with clientUplink, asks /api/uplink-coverage, colours each cell by its best +// class, swaps in the uplink legend, and is deep-linkable (layer=uplink). +// Backend-independent: every /api/* call is mocked. +const assert = require('assert'); +const { chromium } = require('playwright'); +const BASE = process.env.BASE_URL || 'http://localhost:3000'; + +const poly = (lon, lat) => ({ type: 'Polygon', coordinates: [[[lon, lat], [lon + 0.01, lat], [lon + 0.01, lat + 0.01], [lon, lat + 0.01], [lon, lat]]] }); +const cell = (lon, lat, best) => ({ type: 'Feature', geometry: poly(lon, lat), properties: { + cell: 'u' + lon, pings: 2, both: best === 'both' ? 1 : 0, observed: best === 'observed' ? 1 : 0, echo: 0, none: 1, + best, first_hops: ['1403'], max_observers: 17, max_hops: 11, last_at: '2026-10-16T14:51:15.018Z' } }); + +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, { uplink, hash }) { + const page = await browser.newPage(); + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + const uplinkRequests = []; + await page.route('**/api/**', route => { + const url = route.request().url(); + const path = new URL(url).pathname; + if (path === '/api/config/client') return route.fulfill({ json: { clientRxCoverage: true, clientRfSamples: false, clientUplink: uplink } }); + if (path === '/api/config/map') return route.fulfill({ json: { center: [51, 5.4], zoom: 11 } }); + if (path === '/api/rx-leaderboard') return route.fulfill({ json: { observers: [] } }); + if (path === '/api/rx-coverage') return route.fulfill({ json: { type: 'FeatureCollection', features: [] } }); + if (path === '/api/uplink-coverage') { + uplinkRequests.push(url); + return route.fulfill({ json: { type: 'FeatureCollection', features: [cell(5.38, 51.0, 'both'), cell(5.45, 51.0, 'observed')] } }); + } + return route.fulfill({ json: {} }); + }); + await page.goto(BASE + '/#/rx-coverage' + (hash || '')); + await page.waitForSelector('#rxMap.leaflet-container', { timeout: 15000 }); + return { page, errors, uplinkRequests }; +} + +(async () => { + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || undefined, + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], + }); + console.log(`\n=== RX coverage uplink layer E2E against ${BASE} ===`); + + await step('no Uplink button without clientUplink', async () => { + const { page } = await openPage(browser, { uplink: false }); + assert.strictEqual(await page.$('#rxLayerBar button[data-layer="uplink"]'), null); + await page.close(); + }); + + await step('the Uplink button draws coloured cells with the uplink legend', async () => { + const { page, errors, uplinkRequests } = await openPage(browser, { uplink: true }); + await page.click('#rxLayerBar button[data-layer="uplink"]'); + await page.waitForFunction(() => document.querySelectorAll('#rxMap path.leaflet-interactive').length === 2, null, { timeout: 10000 }); + assert.ok(uplinkRequests.length >= 1 && /bbox=/.test(uplinkRequests[0]), 'requested /api/uplink-coverage with a bbox'); + const legend = await page.$eval('#rxLegend', el => el.textContent); + assert.ok(legend.includes('both directions') && legend.includes('not heard'), legend); + const fills = await page.$$eval('#rxMap path.leaflet-interactive', els => els.map(e => e.getAttribute('fill'))); + assert.notStrictEqual(fills[0], fills[1], 'both and observed cells differ in colour'); + assert.ok(/layer=uplink/.test(await page.evaluate(() => location.hash)), 'layer is in the URL'); + assert.deepStrictEqual(errors, []); + await page.close(); + }); + + await step('a layer=uplink deep link opens on the uplink layer', async () => { + const { page, uplinkRequests } = await openPage(browser, { uplink: true, hash: '?layer=uplink&days=7' }); + await page.waitForFunction(() => document.querySelectorAll('#rxMap path.leaflet-interactive').length === 2, null, { timeout: 10000 }); + assert.ok(uplinkRequests.length >= 1); + await page.close(); + }); + + await browser.close(); + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +})().catch(e => { console.error(e); process.exit(1); }); diff --git a/tests/unit/test-rx-coverage-uplink.js b/tests/unit/test-rx-coverage-uplink.js new file mode 100644 index 000000000..dcbc2d20b --- /dev/null +++ b/tests/unit/test-rx-coverage-uplink.js @@ -0,0 +1,47 @@ +'use strict'; +const REPO_ROOT = require('path').resolve(__dirname, '..', '..'); +// Uplink layer (rx-coverage.js): a cell is coloured by the best class of the +// pings sent there, and its tooltip lists the classes, the repeaters that +// picked a ping up, and how far it reached. Loaded in a vm like +// test-rx-coverage-mine.js. +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const code = fs.readFileSync(path.join(REPO_ROOT, 'public', 'rx-coverage.js'), 'utf8'); +const sandbox = { + window: {}, + document: { getElementById: function () { return null; } }, + registerPage: function () {}, + console: { warn: function () {} }, + Promise: Promise, + setTimeout: function () {}, clearTimeout: function () {}, + fetch: function () { throw new Error('no fetch expected'); }, + L: {}, getComputedStyle: function () { return { getPropertyValue: function () { return ''; } }; }, + escapeHtml: function (s) { return String(s).replace(/[&<>"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; }); }, +}; +vm.createContext(sandbox); +vm.runInContext(code, sandbox); +const t = sandbox.window.CSRxCoverage._test; + +assert.strictEqual(t.uplinkColorVar({ best: 'both' }), '--nq-up-both'); +assert.strictEqual(t.uplinkColorVar({ best: 'observed' }), '--nq-up-observed'); +assert.strictEqual(t.uplinkColorVar({ best: 'echo' }), '--nq-up-echo'); +assert.strictEqual(t.uplinkColorVar({ best: 'none' }), '--nq-up-none'); +assert.strictEqual(t.uplinkColorVar({ best: 'bogus' }), '--nq-up-none', 'unknown class falls back to none'); +assert.strictEqual(t.uplinkColorVar(null), '--nq-up-none'); + +const html = t.uplinkCellHtml({ pings: 3, both: 1, observed: 0, echo: 1, none: 1, first_hops: ['c0cd', '1403'], + max_observers: 17, max_hops: 11, last_at: '2026-10-16T14:51:15.018Z' }); +assert.ok(html.includes('3 uplink pings'), html); +assert.ok(html.includes('1 both directions · 1 repeater only · 1 not heard'), html); +assert.ok(!html.includes('heard by observers'), 'zero classes are left out'); +assert.ok(html.includes('picked up by: C0CD, 1403'), html); +assert.ok(html.includes('reached 17 observers, up to 11 hops'), html); +assert.ok(html.includes('2026-10-16 14:51 UTC'), html); +const one = t.uplinkCellHtml({ pings: 1, both: 0, observed: 0, echo: 0, none: 1, first_hops: [''], max_observers: 0, last_at: '' }); +assert.ok(one.includes('1 uplink ping<') && !one.includes('reached'), one); +assert.ok(!one.includes(''), 'first hops are escaped'); + +console.log('rx-coverage uplink layer OK');