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 <noreply@anthropic.com>
This commit is contained in:
dborup
2026-07-20 14:23:56 +02:00
co-authored by Claude Sonnet 5
parent 37e68920ab
commit fb7bb240cc
6 changed files with 174 additions and 22 deletions
+48 -2
View File
@@ -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
}
+1 -1
View File
@@ -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"},
+21 -7
View File
@@ -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 ────────────────────────────────────────────────────────────────────
+38 -10
View File
@@ -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)
}
}
+40 -1
View File
@@ -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 '<div class="stat-card"><div class="stat-value">' + c.value + '</div>' +
'<div class="stat-label">' + c.label + '</div>' +
@@ -5460,6 +5462,37 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
'</svg>';
}
// 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 '<p class="text-muted" style="font-size:0.85em">Insufficient data points to chart.</p>';
}
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 += '<line x1="' + padL + '" y1="' + gy.toFixed(1) + '" x2="' + (W - padR) + '" y2="' + gy.toFixed(1) + '" stroke="var(--border)" stroke-dasharray="2"/>';
grid += '<text x="' + (padL - 4) + '" y="' + (gy + 4).toFixed(1) + '" text-anchor="end" font-size="9" fill="var(--text-muted)">' + gv.toFixed(1) + '</text>';
}
return '<svg viewBox="0 0 ' + W + ' ' + H + '" style="width:100%;max-height:' + H + 'px" role="img" aria-label="' + ariaLabel + '">' +
grid +
'<polyline points="' + pts + '" fill="none" stroke="' + color + '" stroke-width="2"/>' +
'</svg>';
}
function sendersHtml(senders, totalMessages) {
if (!senders || senders.length === 0) {
return '<p class="text-muted" style="font-size:0.85em">No wardriving messages in this window.</p>';
@@ -5564,7 +5597,13 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
'<div id="wardrivingEntryPoints">' + entryHtml + '</div>' +
'<h4 style="margin:24px 0 4px">Coverage by Observer</h4>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">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."</p>' +
'<div id="wardrivingObservers">' + observersHtml(d.observers) + '</div>';
'<div id="wardrivingObservers">' + observersHtml(d.observers) + '</div>' +
'<h4 style="margin:24px 0 4px">Signal Quality Trends</h4>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">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.</p>' +
'<div id="wardrivingSignal" style="display:grid;grid-template-columns:1fr 1fr;gap:16px">' +
'<div><div class="text-muted" style="font-size:0.8em;margin-bottom:4px">Avg SNR (dB)</div>' + signalChartHtml(d.signalTimeSeries, 'avgSnr', 'var(--accent)', 'Average SNR over time') + '</div>' +
'<div><div class="text-muted" style="font-size:0.8em;margin-bottom:4px">Avg RSSI (dBm)</div>' + signalChartHtml(d.signalTimeSeries, 'avgRssi', 'var(--warning, #f39c12)', 'Average RSSI over time') + '</div>' +
'</div>';
} catch (err) {
body = '<div class="text-center" style="color:var(--status-red);padding:20px">Failed to load wardriving stats: ' + esc(String(err)) + '</div>';
}
+26 -1
View File
@@ -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('<svg'), 'at least one SVG chart should render for the 2-point signal series');
});
await testAsync('Top Senders table is sorted by count and shows % of total', async () => {
@@ -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('<div class="stat-value">—</div><div class="stat-label">Avg SNR</div>'), 'Avg SNR card should show a dash when there is no signal data');
assert.ok(el.innerHTML.includes('<div class="stat-value">—</div><div class="stat-label">Avg RSSI</div>'), '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 () => {