From 81cb1a13fc0b77443f976f7c4e41c9599e5eeb63 Mon Sep 17 00:00:00 2001 From: dborup Date: Tue, 21 Jul 2026 15:29:59 +0200 Subject: [PATCH] feat: approximate a wardriving session's area from its entry-point repeater MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For senders who never share a literal GPS fix, resolve the session's path[0] entry-point prefix to a known repeater position (only when it resolves unambiguously, same unique_prefix discipline as /api/resolve-hops) and label it with the most specific configured area — shown as a badge in the Sessions table, clearly marked approximate since it's the repeater's position, not the sender's own. Co-Authored-By: Claude Sonnet 5 --- cmd/server/db.go | 4 ++ cmd/server/routes.go | 40 +++++++++++ cmd/server/types.go | 13 ++++ cmd/server/wardriving_stats_test.go | 107 ++++++++++++++++++++++++++++ public/analytics.js | 8 ++- test-analytics-wardriving-tab.js | 16 +++++ 6 files changed, 186 insertions(+), 2 deletions(-) diff --git a/cmd/server/db.go b/cmd/server/db.go index 97d29455..49cc1bed 100644 --- a/cmd/server/db.go +++ b/cmd/server/db.go @@ -3494,6 +3494,10 @@ func (db *DB) buildWardrivingSessions(channel, since string) ([]WardrivingSessio } cur.EntryPointCount = len(curPrefixes) cur.ObserverCount = len(curObservers) + for p := range curPrefixes { + cur.EntryPointPrefixes = append(cur.EntryPointPrefixes, p) + } + sort.Strings(cur.EntryPointPrefixes) start, errS := time.Parse(time.RFC3339, cur.StartTime) end, errE := time.Parse(time.RFC3339, cur.EndTime) if errS == nil && errE == nil { diff --git a/cmd/server/routes.go b/cmd/server/routes.go index 755a9a92..40fcffd5 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -2716,6 +2716,39 @@ func (s *Server) handleResolveHops(w http.ResponseWriter, r *http.Request) { writeJSON(w, ResolveHopsResponse{Resolved: resolved}) } +// resolveEntryPointArea approximates a wardriving session's area from its +// entry-point repeater(s): for each candidate path[0] prefix, only a +// unique_prefix match (exactly one node in the prefix map, same discipline +// as handleResolveHops) with a known position is trusted — an ambiguous +// prefix is skipped rather than guessed. Returns ok=false if no prefix +// resolves this way, or the resolved node has no position, or areas aren't +// configured. +func (s *Server) resolveEntryPointArea(prefixes []string) (label string, ok bool) { + if s.store == nil || s.cfg == nil || len(s.cfg.Areas) == 0 || len(prefixes) == 0 { + return "", false + } + s.store.mu.RLock() + _, pm := s.store.getCachedNodesAndPM() + s.store.mu.RUnlock() + if pm == nil { + return "", false + } + for _, prefix := range prefixes { + candidates, found := pm.m[strings.ToLower(prefix)] + if !found || len(candidates) != 1 { + continue + } + ni := candidates[0] + if !ni.HasGPS { + continue + } + if label, ok := AreaForPoint(ni.Lat, ni.Lon, s.cfg.Areas); ok { + return label, true + } + } + return "", false +} + func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { region := r.URL.Query().Get("region") includeEncrypted := r.URL.Query().Get("includeEncrypted") == "true" @@ -3782,6 +3815,13 @@ func (s *Server) handleWardrivingStats(w http.ResponseWriter, r *http.Request) { resp.GPSShares[i].Area = &label } } + if s.store != nil { + for i := range resp.Sessions { + if label, ok := s.resolveEntryPointArea(resp.Sessions[i].EntryPointPrefixes); ok { + resp.Sessions[i].Area = &label + } + } + } } s.wardrivingStatsMu.Lock() diff --git a/cmd/server/types.go b/cmd/server/types.go index 9c14bd90..ac1ac5bf 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -285,6 +285,19 @@ type WardrivingSession struct { // TransmissionIDs is internal — the session's own transmission IDs, // used by the route handler to compute AirtimeMs. Never serialized. TransmissionIDs []int64 `json:"-"` + // EntryPointPrefixes is internal — the session's distinct path[0] + // hex prefixes, used by the route handler to resolve Area. Never + // serialized directly. + EntryPointPrefixes []string `json:"-"` + // Area is the most specific configured area containing the session's + // entry-point repeater — always approximate (the repeater's known + // position, not the sender's own; wardriving sessions never carry a + // literal shared GPS fix, unlike WardrivingGPSShare). Resolved by the + // route handler from EntryPointPrefixes when exactly one candidate + // node matches a prefix (same unique_prefix-only discipline as + // /api/resolve-hops) and that node has a known position. Nil when no + // prefix resolves unambiguously or areas aren't configured. + Area *string `json:"area,omitempty"` } // WardrivingGPSShare is one sender who has explicitly shared their own diff --git a/cmd/server/wardriving_stats_test.go b/cmd/server/wardriving_stats_test.go index 981098f0..fe6429b3 100644 --- a/cmd/server/wardriving_stats_test.go +++ b/cmd/server/wardriving_stats_test.go @@ -507,6 +507,113 @@ func TestHandleWardrivingStats_GPSShareArea(t *testing.T) { } } +// TestHandleWardrivingStats_SessionArea covers approximating a session's +// area from its entry-point repeater (path[0]) when the sender never shares +// a literal GPS fix — must only trust a unique_prefix match, same discipline +// as /api/resolve-hops, and must never guess when a prefix is ambiguous or +// unresolvable. +func TestHandleWardrivingStats_SessionArea(t *testing.T) { + srv, router := setupTestServer(t) + if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil { + t.Fatalf("clear transmissions: %v", err) + } + if _, err := srv.db.conn.Exec(`DELETE FROM observations`); err != nil { + t.Fatalf("clear observations: %v", err) + } + + f := func(v float64) *float64 { return &v } + srv.cfg.Areas = map[string]AreaEntry{ + "ODE": {Label: "Odense by", LatMin: f(55.32), LatMax: f(55.45), LonMin: f(10.3), LonMax: f(10.5)}, + } + + // A repeater inside the configured area, resolvable only via a unique + // full-length prefix match (mirrors TestResolveHopsAPI_UniquePrefix). + if _, err := srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)", + "ff11223344", "OdenseRepeater", 55.4047, 10.3810, "repeater"); err != nil { + t.Fatalf("insert node: %v", err) + } + // A second, ambiguous prefix: two nodes share it, so it must never + // resolve to an area even though one of them has a position. + srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)", + "ee1aaaaaaa", "AmbigA", 55.40, 10.38, "repeater") + srv.db.conn.Exec("INSERT OR IGNORE INTO nodes (public_key, name, lat, lon, role) VALUES (?, ?, ?, ?, ?)", + "ee1bbbbbbb", "AmbigB", 55.41, 10.39, "repeater") + srv.store.InvalidateNodeCache() + + now := time.Now().UTC() + insertTx := func(hash, sender string, ts time.Time) int64 { + res, err := srv.db.conn.Exec( + `INSERT INTO transmissions (raw_hex,hash,first_seen,route_type,payload_type,channel_hash,decoded_json) VALUES (?,?,?,1,5,'#wardriving',?)`, + "aa", hash, ts.Format(time.RFC3339), `{"sender":"`+sender+`","text":"`+sender+`: MM:x"}`, + ) + if err != nil { + t.Fatalf("insert tx %s: %v", hash, err) + } + id, _ := res.LastInsertId() + return id + } + seaIdx := int64(0) + res, err := srv.db.conn.Exec(`INSERT INTO observers (id, name, iata) VALUES (?,?,?)`, "obsSEA", "SeattleObs", "SEA") + if err != nil { + t.Fatalf("insert observer: %v", err) + } + seaIdx, _ = res.LastInsertId() + insertObs := func(txID int64, pathJSON string) { + if _, err := srv.db.conn.Exec( + `INSERT INTO observations (transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES (?,?,?,?,?,?)`, + txID, seaIdx, 1.0, -90.0, pathJSON, time.Now().Unix(), + ); err != nil { + t.Fatalf("insert observation: %v", err) + } + } + + txResolvable := insertTx("area1", "InOdense", now.Add(-10*time.Minute)) + insertObs(txResolvable, `["ff11223344"]`) + + txAmbiguous := insertTx("area2", "Ambiguous", now.Add(-10*time.Minute)) + insertObs(txAmbiguous, `["ee1"]`) // shared prefix of ee1aaaaaaa and ee1bbbbbbb -- 2 candidates + + txNoMatch := insertTx("area3", "NoMatch", now.Add(-10*time.Minute)) + insertObs(txNoMatch, `["deadbeef99"]`) + + req := httptest.NewRequest("GET", "/api/analytics/wardriving?window=24h", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var resp WardrivingStatsResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v body=%s", err, w.Body.String()) + } + + bySender := map[string]WardrivingSession{} + for _, s := range resp.Sessions { + bySender[s.Sender] = s + } + inOdense, ok := bySender["InOdense"] + if !ok { + t.Fatal("InOdense session missing") + } + if inOdense.Area == nil || *inOdense.Area != "Odense by" { + t.Errorf("InOdense.Area = %v, want \"Odense by\" (unique_prefix match with a known position)", inOdense.Area) + } + ambiguous, ok := bySender["Ambiguous"] + if !ok { + t.Fatal("Ambiguous session missing") + } + if ambiguous.Area != nil { + t.Errorf("Ambiguous.Area = %v, want nil (prefix matches 2 candidates, must not guess)", *ambiguous.Area) + } + noMatch, ok := bySender["NoMatch"] + if !ok { + t.Fatal("NoMatch session missing") + } + if noMatch.Area != nil { + t.Errorf("NoMatch.Area = %v, want nil (prefix matches no known node)", *noMatch.Area) + } +} + // TestHandleWardrivingStats_InvalidWindow mirrors the existing scope-stats // window validation. func TestHandleWardrivingStats_InvalidWindow(t *testing.T) { diff --git a/public/analytics.js b/public/analytics.js index 43e1b04a..d2883880 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -5545,16 +5545,20 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf } var shown = expanded ? sessions : sessions.slice(0, TOP_N_LIMIT); var rows = shown.map(function(s) { + var areaBadge = s.area + ? '' + esc(s.area) + '' + : '—'; return '' + senderTriggerHtml(s.sender, s.startTime, s.endTime) + '' + '' + (typeof timeAgo === 'function' ? timeAgo(s.startTime) : s.startTime) + '' + '' + formatSessionDuration(s.durationMinutes) + '' + '' + s.messageCount.toLocaleString() + '' + '' + s.entryPointCount.toLocaleString() + '' + '' + s.observerCount.toLocaleString() + '' + + '' + areaBadge + '' + '' + formatAirtimeMs(s.airtimeMs) + ''; }).join(''); - return '' + - '' + + return '
SenderStartedDurationMessagesEntry PointsObserversAirtime
' + + '' + '' + rows + '' + '
SenderStartedDurationMessagesEntry PointsObserversAreaAirtime
' + topNToggleHtml(sessions.length, expanded, 'sessions'); } diff --git a/test-analytics-wardriving-tab.js b/test-analytics-wardriving-tab.js index c227c190..d281ed9f 100644 --- a/test-analytics-wardriving-tab.js +++ b/test-analytics-wardriving-tab.js @@ -351,6 +351,22 @@ function makeApiStub(wardrivingResp, resolveHopsResp) { assert.ok(section.includes('—'), 'a session with no airtime data (DB-only mode) should show a dash, not blank/null'); }); + await testAsync('Sessions table shows the approximate entry-point area, and a dash when unresolved', async () => { + const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({ + sessions: [ + { sender: 'Alice', startTime: '2026-07-20T09:00:00Z', endTime: '2026-07-20T09:00:00Z', durationMinutes: 0, messageCount: 1, entryPointCount: 1, observerCount: 1, airtimeMs: 245, area: 'Odense by' }, + { sender: 'Bob', startTime: '2026-07-20T08:30:00Z', endTime: '2026-07-20T08:30:00Z', durationMinutes: 0, messageCount: 1, entryPointCount: 1, observerCount: 1, airtimeMs: null }, + ], + }))); + const el = fakeEl(); + await ctx.window._analyticsRenderWardrivingTab(el); + const startIdx = el.innerHTML.indexOf('id="wardrivingSessions"'); + const endIdx = el.innerHTML.indexOf('id="wardrivingEntryPoints"'); + const section = el.innerHTML.slice(startIdx, endIdx); + assert.ok(section.includes('Area'), 'Sessions table should have an Area column'); + assert.ok(section.includes('>Odense by<'), 'a resolved area should render as a badge'); + }); + await testAsync('Entry Points resolves unique_prefix repeaters and folds ambiguous into one bucket', async () => { const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse(), { resolved: {