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)
}
}