feat: approximate a wardriving session's area from its entry-point repeater

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 <noreply@anthropic.com>
This commit is contained in:
dborup
2026-07-21 15:29:59 +02:00
co-authored by Claude Sonnet 5
parent 9e476fc5c2
commit 81cb1a13fc
6 changed files with 186 additions and 2 deletions
+4
View File
@@ -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 {
+40
View File
@@ -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()
+13
View File
@@ -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
+107
View File
@@ -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) {
+6 -2
View File
@@ -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
? '<span class="badge" style="background:var(--border);color:var(--text)" title="Approximate — the entry-point repeater\'s known position, not the sender\'s own">' + esc(s.area) + '</span>'
: '<span class="text-muted" style="font-size:0.85em">—</span>';
return '<tr><td>' + senderTriggerHtml(s.sender, s.startTime, s.endTime) + '</td>' +
'<td>' + (typeof timeAgo === 'function' ? timeAgo(s.startTime) : s.startTime) + '</td>' +
'<td>' + formatSessionDuration(s.durationMinutes) + '</td>' +
'<td>' + s.messageCount.toLocaleString() + '</td>' +
'<td>' + s.entryPointCount.toLocaleString() + '</td>' +
'<td>' + s.observerCount.toLocaleString() + '</td>' +
'<td>' + areaBadge + '</td>' +
'<td>' + formatAirtimeMs(s.airtimeMs) + '</td></tr>';
}).join('');
return '<table class="data-table analytics-table" data-wd-cols="7">' +
'<thead><tr><th>Sender</th><th>Started</th><th>Duration</th><th>Messages</th><th>Entry Points</th><th>Observers</th><th>Airtime</th></tr></thead>' +
return '<table class="data-table analytics-table" data-wd-cols="8">' +
'<thead><tr><th>Sender</th><th>Started</th><th>Duration</th><th>Messages</th><th>Entry Points</th><th>Observers</th><th>Area</th><th>Airtime</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' + topNToggleHtml(sessions.length, expanded, 'sessions');
}
+16
View File
@@ -351,6 +351,22 @@ function makeApiStub(wardrivingResp, resolveHopsResp) {
assert.ok(section.includes('<td>—</td></tr>'), '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('<th>Area</th>'), '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: {