From fb7bb240ccf4714e00a7628ad56e09c6881b60d2 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 20 Jul 2026 14:23:56 +0200 Subject: [PATCH] feat: Wardriving signal quality trends (idea 4) Extends GET /api/analytics/wardriving with avgSnr/avgRssi over the same time buckets as the activity series, plus overall averages. Frontend adds two min/max-scaled line charts (SNR has no natural zero floor, RSSI is negative dBm, so these can't reuse the 0-baseline message-volume chart) and two stat cards. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 50 +++++++++++++++++++++++++++-- cmd/server/openapi.go | 2 +- cmd/server/types.go | 28 ++++++++++++---- cmd/server/wardriving_stats_test.go | 48 +++++++++++++++++++++------ public/analytics.js | 41 ++++++++++++++++++++++- test-analytics-wardriving-tab.js | 27 +++++++++++++++- 6 files changed, 174 insertions(+), 22 deletions(-) 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 '
' + c.value + '
' + '
' + c.label + '
' + @@ -5460,6 +5462,37 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf ''; } + // Signal quality line chart — scaled to the data's own min/max (unlike + // chartHtml's 0-baseline) since SNR and RSSI have no natural zero floor. + function signalChartHtml(sig, key, color, ariaLabel) { + if (!sig || sig.length <= 1) { + 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 += ''; + grid += '' + gv.toFixed(1) + ''; + } + return '' + + grid + + '' + + ''; + } + function sendersHtml(senders, totalMessages) { if (!senders || senders.length === 0) { return '

No wardriving messages in this window.

'; @@ -5564,7 +5597,13 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf '
' + entryHtml + '
' + '

Coverage by Observer

' + '

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."

' + - '
' + observersHtml(d.observers) + '
'; + '
' + observersHtml(d.observers) + '
' + + '

Signal Quality Trends

' + + '

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.

' + + '
' + + '
Avg SNR (dB)
' + signalChartHtml(d.signalTimeSeries, 'avgSnr', 'var(--accent)', 'Average SNR over time') + '
' + + '
Avg RSSI (dBm)
' + signalChartHtml(d.signalTimeSeries, 'avgRssi', 'var(--warning, #f39c12)', 'Average RSSI over time') + '
' + + '
'; } catch (err) { body = '
Failed to load wardriving stats: ' + esc(String(err)) + '
'; } diff --git a/test-analytics-wardriving-tab.js b/test-analytics-wardriving-tab.js index b04ecc15..58c3f714 100644 --- a/test-analytics-wardriving-tab.js +++ b/test-analytics-wardriving-tab.js @@ -111,7 +111,8 @@ function fakeEl() { // Minimal but representative /api/analytics/wardriving fixture: 2 senders, // 2 entry-point prefixes (one unique_prefix-resolvable, one ambiguous), -// 2 observers (one with known coordinates, one without). +// 2 observers (one with known coordinates, one without), and a 2-point +// signal-quality time series. function makeWardrivingResponse(overrides) { return Object.assign({ window: '24h', @@ -130,6 +131,12 @@ function makeWardrivingResponse(overrides) { { observerId: '1', observerName: 'SeattleObs', iata: 'SEA', lat: 47.4502, lon: -122.3088, observationCount: 3, messageCount: 3 }, { observerId: '2', observerName: 'UnknownObs', iata: 'ZZZ', observationCount: 1, messageCount: 1 }, ], + signalTimeSeries: [ + { t: '2026-07-20T08:00:00Z', avgSnr: 4.0, avgRssi: -80.0, observationCount: 1 }, + { t: '2026-07-20T09:00:00Z', avgSnr: 6.0, avgRssi: -70.0, observationCount: 3 }, + ], + avgSnr: 5.5, + avgRssi: -72.5, }, overrides); } @@ -156,6 +163,20 @@ function makeApiStub(wardrivingResp, resolveHopsResp) { assert.ok(el.innerHTML.includes('Active Senders'), 'Active Senders card label should render'); assert.ok(el.innerHTML.includes('Entry-Point Repeaters'), 'Entry-Point Repeaters card label should render'); assert.ok(el.innerHTML.includes('Observers Reached'), 'Observers Reached card label should render'); + assert.ok(el.innerHTML.includes('5.5 dB'), 'Avg SNR card should show the API-provided average'); + assert.ok(el.innerHTML.includes('-72.5 dBm'), 'Avg RSSI card should show the API-provided average'); + }); + + await testAsync('Signal Quality Trends renders both SNR and RSSI charts', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse())); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + const startIdx = el.innerHTML.indexOf('Signal Quality Trends'); + assert.ok(startIdx > -1, 'Signal Quality Trends heading should render'); + const section = el.innerHTML.slice(startIdx); + assert.ok(section.includes('Avg SNR (dB)'), 'SNR chart label should render'); + assert.ok(section.includes('Avg RSSI (dBm)'), 'RSSI chart label should render'); + assert.ok(section.includes(' { @@ -206,12 +227,16 @@ function makeApiStub(wardrivingResp, resolveHopsResp) { await testAsync('shows empty-state messages when the window has no wardriving activity', async () => { const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({ totalMessages: 0, topSenders: [], entryPoints: [], observers: [], timeSeries: [], + signalTimeSeries: [], avgSnr: null, avgRssi: null, }))); const el = fakeEl(); await ctx.window._analyticsRenderWardrivingTab(el); assert.ok(el.innerHTML.includes('No wardriving messages in this window'), 'senders empty state should show'); assert.ok(el.innerHTML.includes('No wardriving messages with a relay path'), 'entry points empty state should show'); assert.ok(el.innerHTML.includes('No observer has heard wardriving traffic'), 'observers empty state should show'); + assert.ok(el.innerHTML.includes('Insufficient data points to chart'), 'signal chart empty state should show'); + assert.ok(el.innerHTML.includes('
—
Avg SNR
'), 'Avg SNR card should show a dash when there is no signal data'); + assert.ok(el.innerHTML.includes('
—
Avg RSSI
'), 'Avg RSSI card should show a dash when there is no signal data'); }); await testAsync('rendering registers a real interval, and stop() actually clears it (not a no-op)', async () => {