diff --git a/cmd/server/db.go b/cmd/server/db.go index 97377fcb..ded9c37e 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -3173,8 +3173,9 @@ func (db *DB) getChannelScopeRegions(since string) (map[string][]string, error) // GetWardrivingStats aggregates activity on the given channel (normally // "#wardriving") over the requested window: message volume over time, who's // actively sending, which repeater first relayed each message (raw hash -// prefixes — the caller resolves names via /api/resolve-hops), and which -// observer stations actually heard the traffic. See WardrivingObserverCoverage +// prefixes — the caller resolves names via /api/resolve-hops), which +// observer stations actually heard the traffic, and signal quality (SNR/RSSI) +// over the same time buckets as the activity series. See WardrivingObserverCoverage // doc for why observer coverage — not sender GPS — is the reliable half of // a "where did this reach" picture: MeshMapper's #wardriving messages carry // an anonymous per-session token by default, not the sender's live @@ -3326,6 +3327,51 @@ func (db *DB) GetWardrivingStats(window, channel string) (*WardrivingStatsRespon return nil, fmt.Errorf("wardriving observers iteration: %w", err) } + // Signal quality over time — same bucketing as the activity time series, + // but averaged across every observation (any observer) in that bucket. + sigBucketExpr := strings.ReplaceAll(bucketExpr, "first_seen", "t.first_seen") + sigQuery := fmt.Sprintf(` + SELECT %s AS bucket, AVG(o.snr) AS avg_snr, AVG(o.rssi) AS avg_rssi, COUNT(*) AS cnt + FROM observations o + JOIN transmissions t ON t.id = o.transmission_id + WHERE t.channel_hash = ? AND t.payload_type = 5 AND t.first_seen >= ? + GROUP BY bucket + ORDER BY bucket + `, sigBucketExpr) + sigRows, err := db.conn.Query(sigQuery, channel, since) + if err != nil { + return nil, fmt.Errorf("wardriving signal timeseries query: %w", err) + } + resp.SignalTimeSeries = make([]WardrivingSignalPoint, 0) + for sigRows.Next() { + var sp WardrivingSignalPoint + if sigRows.Scan(&sp.T, &sp.AvgSNR, &sp.AvgRSSI, &sp.ObservationCount) == nil { + resp.SignalTimeSeries = append(resp.SignalTimeSeries, sp) + } + } + sigRows.Close() + if err := sigRows.Err(); err != nil { + return nil, fmt.Errorf("wardriving signal timeseries iteration: %w", err) + } + + var avgSNR, avgRSSI sql.NullFloat64 + if err := db.conn.QueryRow(` + SELECT AVG(o.snr), AVG(o.rssi) + FROM observations o + JOIN transmissions t ON t.id = o.transmission_id + WHERE t.channel_hash = ? AND t.payload_type = 5 AND t.first_seen >= ? + `, channel, since).Scan(&avgSNR, &avgRSSI); err != nil { + return nil, fmt.Errorf("wardriving avg signal query: %w", err) + } + if avgSNR.Valid { + v := avgSNR.Float64 + resp.AvgSNR = &v + } + if avgRSSI.Valid { + v := avgRSSI.Float64 + resp.AvgRSSI = &v + } + return resp, nil } diff --git a/cmd/server/openapi.go b/cmd/server/openapi.go index c3c98f39..7e05fec8 100644 --- a/cmd/server/openapi.go +++ b/cmd/server/openapi.go @@ -102,7 +102,7 @@ func routeDescriptions() map[string]routeMeta { "GET /api/analytics/subpaths-bulk": {Summary: "Bulk subpath analysis", Tag: "analytics"}, "GET /api/analytics/subpath-detail": {Summary: "Subpath detail", Tag: "analytics"}, "GET /api/analytics/neighbor-graph": {Summary: "Neighbor graph", Description: "Full neighbor affinity graph for visualization.", Tag: "analytics"}, - "GET /api/analytics/wardriving": {Summary: "Wardriving channel analytics", Description: "Activity/entry-point/coverage analytics for the #wardriving channel (or another channel via ?channel=): message volume over time, top senders, path[0] entry-point hash-prefix tallies (resolve names via /api/resolve-hops), and per-observer coverage (observer's known IATA-derived coordinates, not the sender's — MeshMapper's wardriving messages carry an anonymous session token by default, not live GPS). Cached 30s per window+channel.", Tag: "analytics", + "GET /api/analytics/wardriving": {Summary: "Wardriving channel analytics", Description: "Activity/entry-point/coverage/signal analytics for the #wardriving channel (or another channel via ?channel=): message volume over time, top senders, path[0] entry-point hash-prefix tallies (resolve names via /api/resolve-hops), per-observer coverage (observer's known IATA-derived coordinates, not the sender's — MeshMapper's wardriving messages carry an anonymous session token by default, not live GPS), and average SNR/RSSI over the same time buckets as the activity series. Cached 30s per window+channel.", Tag: "analytics", QueryParams: []paramMeta{ {Name: "window", Description: "Time window: 1h, 24h (default), or 7d", Type: "string"}, {Name: "channel", Description: "Channel name to analyze (default #wardriving)", Type: "string"}, diff --git a/cmd/server/types.go b/cmd/server/types.go index 55d50032..09eb245f 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -251,14 +251,28 @@ type WardrivingObserverCoverage struct { MessageCount int `json:"messageCount"` // distinct transmissions this observer heard } +// WardrivingSignalPoint is one time bucket of average signal quality across +// every observation of #wardriving traffic in that bucket (not per-observer — +// see WardrivingObserverCoverage for the per-station breakdown). Always has +// ObservationCount >= 1 since buckets only exist where there was traffic. +type WardrivingSignalPoint struct { + T string `json:"t"` + AvgSNR float64 `json:"avgSnr"` + AvgRSSI float64 `json:"avgRssi"` + ObservationCount int `json:"observationCount"` +} + type WardrivingStatsResponse struct { - Window string `json:"window"` - Channel string `json:"channel"` - TotalMessages int `json:"totalMessages"` - TimeSeries []WardrivingTimePoint `json:"timeSeries"` - TopSenders []WardrivingSenderCount `json:"topSenders"` - EntryPoints []WardrivingEntryPrefix `json:"entryPoints"` - Observers []WardrivingObserverCoverage `json:"observers"` + Window string `json:"window"` + Channel string `json:"channel"` + TotalMessages int `json:"totalMessages"` + TimeSeries []WardrivingTimePoint `json:"timeSeries"` + TopSenders []WardrivingSenderCount `json:"topSenders"` + EntryPoints []WardrivingEntryPrefix `json:"entryPoints"` + Observers []WardrivingObserverCoverage `json:"observers"` + SignalTimeSeries []WardrivingSignalPoint `json:"signalTimeSeries"` + AvgSNR *float64 `json:"avgSnr,omitempty"` + AvgRSSI *float64 `json:"avgRssi,omitempty"` } // ─── Health ──────────────────────────────────────────────────────────────────── diff --git a/cmd/server/wardriving_stats_test.go b/cmd/server/wardriving_stats_test.go index a3a86b5a..f830a711 100644 --- a/cmd/server/wardriving_stats_test.go +++ b/cmd/server/wardriving_stats_test.go @@ -62,21 +62,23 @@ func TestHandleWardrivingStats(t *testing.T) { seaIdx := insertObserver("obsSEA", "SeattleObs", "SEA") zzzIdx := insertObserver("obsXXX", "UnknownObs", "ZZZ") - insertObs := func(txID int64, observerIdx int64, pathJSON string) { + insertObs := func(txID int64, observerIdx int64, pathJSON string, snr, rssi float64) { if _, err := srv.db.conn.Exec( - `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,1.0,-90,?,?)`, - txID, observerIdx, pathJSON, time.Now().Unix(), + `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`, + txID, observerIdx, snr, rssi, pathJSON, time.Now().Unix(), ); err != nil { t.Fatalf("insert observation: %v", err) } } // tx1: two observations, both via entry prefix "AAAA", one from each observer. - insertObs(tx1, seaIdx, `["AAAA","1111"]`) - insertObs(tx1, zzzIdx, `["AAAA","2222"]`) + // Signal values are distinct so the avg-SNR/avg-RSSI math is verifiable: + // avg SNR = (2+4+6+8)/4 = 5.0, avg RSSI = (-80-100-70-60)/4 = -77.5. + insertObs(tx1, seaIdx, `["AAAA","1111"]`, 2.0, -80) + insertObs(tx1, zzzIdx, `["AAAA","2222"]`, 4.0, -100) // tx2: entry prefix "BBBB", heard only by SEA. - insertObs(tx2, seaIdx, `["BBBB"]`) + insertObs(tx2, seaIdx, `["BBBB"]`, 6.0, -70) // tx3: entry prefix "AAAA" again (same prefix as tx1 — tallies together), heard by SEA. - insertObs(tx3, seaIdx, `["AAAA","3333"]`) + insertObs(tx3, seaIdx, `["AAAA","3333"]`, 8.0, -60) req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil) w := httptest.NewRecorder() @@ -150,6 +152,29 @@ func TestHandleWardrivingStats(t *testing.T) { if sum != 3 { t.Errorf("TimeSeries sums to %d, want 3", sum) } + + // Signal quality: all 4 observations land in the same hourly bucket + // (inserted back-to-back "now"), so there's exactly one signal point + // averaging all 4 readings: avg SNR = 5.0, avg RSSI = -77.5. + if len(resp.SignalTimeSeries) != 1 { + t.Fatalf("SignalTimeSeries = %+v, want 1 bucket", resp.SignalTimeSeries) + } + sig := resp.SignalTimeSeries[0] + if sig.ObservationCount != 4 { + t.Errorf("SignalTimeSeries[0].ObservationCount = %d, want 4", sig.ObservationCount) + } + if sig.AvgSNR != 5.0 { + t.Errorf("SignalTimeSeries[0].AvgSNR = %v, want 5.0", sig.AvgSNR) + } + if sig.AvgRSSI != -77.5 { + t.Errorf("SignalTimeSeries[0].AvgRSSI = %v, want -77.5", sig.AvgRSSI) + } + if resp.AvgSNR == nil || *resp.AvgSNR != 5.0 { + t.Errorf("AvgSNR = %v, want 5.0", resp.AvgSNR) + } + if resp.AvgRSSI == nil || *resp.AvgRSSI != -77.5 { + t.Errorf("AvgRSSI = %v, want -77.5", resp.AvgRSSI) + } } // TestHandleWardrivingStats_InvalidWindow mirrors the existing scope-stats @@ -186,8 +211,11 @@ func TestHandleWardrivingStats_EmptyChannel(t *testing.T) { if resp.TotalMessages != 0 { t.Errorf("TotalMessages = %d, want 0", resp.TotalMessages) } - if resp.TopSenders == nil || resp.EntryPoints == nil || resp.Observers == nil || resp.TimeSeries == nil { - t.Errorf("expected empty (non-nil) slices, got TopSenders=%v EntryPoints=%v Observers=%v TimeSeries=%v", - resp.TopSenders, resp.EntryPoints, resp.Observers, resp.TimeSeries) + if resp.TopSenders == nil || resp.EntryPoints == nil || resp.Observers == nil || resp.TimeSeries == nil || resp.SignalTimeSeries == nil { + t.Errorf("expected empty (non-nil) slices, got TopSenders=%v EntryPoints=%v Observers=%v TimeSeries=%v SignalTimeSeries=%v", + resp.TopSenders, resp.EntryPoints, resp.Observers, resp.TimeSeries, resp.SignalTimeSeries) + } + if resp.AvgSNR != nil || resp.AvgRSSI != nil { + t.Errorf("expected nil AvgSNR/AvgRSSI for a channel with no observations, got AvgSNR=%v AvgRSSI=%v", resp.AvgSNR, resp.AvgRSSI) } } diff --git a/public/analytics.js b/public/analytics.js index 00691aa6..31b052bc 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5424,6 +5424,8 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf { label: 'Active Senders', value: (d.topSenders || []).length.toLocaleString(), note: null }, { label: 'Entry-Point Repeaters', value: (d.entryPoints || []).length.toLocaleString(), note: 'distinct path[0] prefixes' }, { label: 'Observers Reached', value: (d.observers || []).length.toLocaleString(), note: null }, + { label: 'Avg SNR', value: (d.avgSnr != null ? d.avgSnr.toFixed(1) + ' dB' : '—'), note: null }, + { label: 'Avg RSSI', value: (d.avgRssi != null ? d.avgRssi.toFixed(1) + ' dBm' : '—'), note: null }, ].map(function(c) { return '
Insufficient data points to chart.
'; + } + var vals = sig.map(function(p) { return p[key]; }); + var minVal = Math.min.apply(null, vals); + var maxVal = Math.max.apply(null, vals); + if (minVal === maxVal) { minVal -= 1; maxVal += 1; } + var W = 800, H = 140, padL = 44, padT = 10, padR = 10, padB = 20; + var plotW = W - padL - padR, plotH = H - padB - padT; + var n = sig.length; + var pts = vals.map(function(v, i) { + var x = padL + i * plotW / Math.max(n - 1, 1); + var y = padT + plotH - ((v - minVal) / (maxVal - minVal)) * plotH; + return x.toFixed(1) + ',' + y.toFixed(1); + }).join(' '); + var grid = ''; + for (var gi = 0; gi <= 3; gi++) { + var gy = padT + plotH * gi / 3; + var gv = maxVal - (maxVal - minVal) * gi / 3; + grid += 'No wardriving messages in this window.
'; @@ -5564,7 +5597,13 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf 'Which observer stations actually heard wardriving traffic — observers sit at fixed, known locations, so this is the reliable half of "how far did it reach."
' + - 'Average SNR and RSSI across every observation of wardriving traffic in each time bucket — a rough proxy for link quality, not tied to any one observer.
' + + '