mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-27 04:47:57 +00:00
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:
co-authored by
Claude Sonnet 5
parent
37e68920ab
commit
fb7bb240cc
+48
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user