mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-25 23:33:35 +00:00
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:
co-authored by
Claude Sonnet 5
parent
9e476fc5c2
commit
81cb1a13fc
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user