feat: Wardriving coordinate-broadcast detection (idea 6)

Extends GET /api/analytics/wardriving with a payload-anomaly detector:
every "MM:<base64>" message is checked against the standard 7-byte
anonymous session token (confirmed empirically against live traffic).
Non-standard lengths or undecodable payloads are grouped per sender
as a candidate signal that MeshMapper's optional "Broadcast My
Coordinates" mode is active — surfaced as a raw hex dump, never
interpreted as lat/lon, since that mode's byte format is undocumented.
Messages without the "MM:" prefix (plain channel chat) are ignored
entirely rather than polluting either bucket.

Frontend adds a Payload Anomalies table plus a stat card, completing
all 6 originally-proposed wardriving analytics ideas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dborup
2026-07-20 15:18:01 +02:00
co-authored by Claude Sonnet 5
parent 5ee832a38c
commit 3453caee53
6 changed files with 280 additions and 19 deletions
+106 -3
View File
@@ -2,6 +2,8 @@ package main
import (
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log"
@@ -3175,9 +3177,11 @@ func (db *DB) getChannelScopeRegions(since string) (map[string][]string, error)
// actively sending, which repeater first relayed each message (raw hash
// prefixes — the caller resolves names via /api/resolve-hops), which
// observer stations actually heard the traffic, signal quality (SNR/RSSI)
// over the same time buckets as the activity series, and each sender's
// messages grouped into distinct sessions/runs (see buildWardrivingSessions).
// See WardrivingObserverCoverage
// over the same time buckets as the activity series, each sender's messages
// grouped into distinct sessions/runs (see buildWardrivingSessions), and any
// payload anomalies suggesting a sender might be broadcasting real
// coordinates rather than the standard anonymous token (see
// detectWardrivingAnomalies). 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
@@ -3380,6 +3384,13 @@ func (db *DB) GetWardrivingStats(window, channel string) (*WardrivingStatsRespon
}
resp.Sessions = sessions
standardCount, anomalies, err := db.detectWardrivingAnomalies(channel, since)
if err != nil {
return nil, err
}
resp.StandardPayloadCount = standardCount
resp.Anomalies = anomalies
return resp, nil
}
@@ -3516,6 +3527,98 @@ func (db *DB) buildWardrivingSessions(channel, since string) ([]WardrivingSessio
return sessions, nil
}
// wardrivingStandardPayloadBytes is the decoded length of MeshMapper's
// default anonymous per-session wardriving token — confirmed empirically
// (every "MM:<base64>" payload observed decodes to exactly this many
// bytes). Anything else is a candidate for MeshMapper's optional
// "Broadcast My Coordinates" mode, whose on-air byte format is
// undocumented — see WardrivingAnomaly doc for why we detect but don't
// attempt to decode it.
const wardrivingStandardPayloadBytes = 7
// detectWardrivingAnomalies scans every #wardriving message's "MM:<base64>"
// payload (messages without that prefix — e.g. manual human chat on the
// channel — are ignored entirely, not counted either way) and buckets them
// into the standard-length count vs per-sender anomalies for any
// non-standard length or undecodable payload.
func (db *DB) detectWardrivingAnomalies(channel, since string) (int, []WardrivingAnomaly, error) {
rows, err := db.conn.Query(`
SELECT json_extract(decoded_json, '$.sender'), json_extract(decoded_json, '$.text'), first_seen
FROM transmissions
WHERE channel_hash = ? AND payload_type = 5 AND first_seen >= ?
`, channel, since)
if err != nil {
return 0, nil, fmt.Errorf("wardriving anomaly query: %w", err)
}
defer rows.Close()
type anomalyAgg struct {
count int
byteSet map[int]bool
sampleHex string
lastSeen string
}
standardCount := 0
agg := make(map[string]*anomalyAgg)
for rows.Next() {
var sender, text sql.NullString
var ts string
if err := rows.Scan(&sender, &text, &ts); err != nil {
continue
}
if !text.Valid || !strings.HasPrefix(text.String, "MM:") {
continue
}
payload := strings.TrimPrefix(text.String, "MM:")
decoded, decErr := base64.RawURLEncoding.DecodeString(payload)
if decErr == nil && len(decoded) == wardrivingStandardPayloadBytes {
standardCount++
continue
}
senderName := sender.String
if senderName == "" {
senderName = "(unknown sender)"
}
n := -1 // undecodable base64
if decErr == nil {
n = len(decoded)
}
if agg[senderName] == nil {
agg[senderName] = &anomalyAgg{byteSet: make(map[int]bool)}
}
a := agg[senderName]
a.count++
a.byteSet[n] = true
if ts >= a.lastSeen {
a.lastSeen = ts
a.sampleHex = hex.EncodeToString(decoded)
}
}
if err := rows.Err(); err != nil {
return 0, nil, fmt.Errorf("wardriving anomaly iteration: %w", err)
}
anomalies := make([]WardrivingAnomaly, 0, len(agg))
for senderName, a := range agg {
bytesList := make([]int, 0, len(a.byteSet))
for b := range a.byteSet {
bytesList = append(bytesList, b)
}
sort.Ints(bytesList)
anomalies = append(anomalies, WardrivingAnomaly{
Sender: senderName,
MessageCount: a.count,
PayloadBytes: bytesList,
SampleHex: a.sampleHex,
LastSeen: a.lastSeen,
})
}
sort.Slice(anomalies, func(i, j int) bool { return anomalies[i].MessageCount > anomalies[j].MessageCount })
return standardCount, anomalies, nil
}
// GetMatchedRegionNames returns the set of scope_name values that have ever
// matched at least one transmission still in retention (NULL and empty-string
// "unknown" rows are excluded). Used to diff against the operator's
+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/signal/session 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), average SNR/RSSI over the same time buckets as the activity series, and each sender's messages grouped into distinct sessions/runs (split on a 15-minute gap). Cached 30s per window+channel.", Tag: "analytics",
"GET /api/analytics/wardriving": {Summary: "Wardriving channel analytics", Description: "Activity/entry-point/coverage/signal/session/anomaly 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), average SNR/RSSI over the same time buckets as the activity series, each sender's messages grouped into distinct sessions/runs (split on a 15-minute gap), and per-sender payload anomalies: messages whose \"MM:<base64>\" payload doesn't decode to the standard 7-byte token, a candidate signal that MeshMapper's optional coordinate-broadcast mode is active (payload bytes are surfaced as a raw hex dump, never interpreted as lat/lon — that byte format is undocumented). 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"},
+30 -11
View File
@@ -276,18 +276,37 @@ type WardrivingSession struct {
ObserverCount int `json:"observerCount"` // distinct observers that heard any message in the session
}
// WardrivingAnomaly aggregates, per sender, every #wardriving message whose
// "MM:<base64>" payload does NOT decode to the standard
// wardrivingStandardPayloadBytes-byte anonymous session token — either a
// different length or genuinely undecodable base64. This is a detector,
// not a decoder: MeshMapper's optional "Broadcast My Coordinates" mode
// would produce on-air payloads in an undocumented format, so a
// non-standard length is a plausible signal that mode is active for that
// sender, but the payload bytes are deliberately NOT interpreted as
// lat/lon — see SampleHex, which is a raw hex dump for manual inspection.
type WardrivingAnomaly struct {
Sender string `json:"sender"`
MessageCount int `json:"messageCount"`
PayloadBytes []int `json:"payloadBytes"` // distinct decoded byte-lengths seen, sorted ascending (-1 marks undecodable base64)
SampleHex string `json:"sampleHex"` // hex dump of the most recent non-standard payload, for manual inspection
LastSeen string `json:"lastSeen"`
}
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"`
SignalTimeSeries []WardrivingSignalPoint `json:"signalTimeSeries"`
AvgSNR *float64 `json:"avgSnr,omitempty"`
AvgRSSI *float64 `json:"avgRssi,omitempty"`
Sessions []WardrivingSession `json:"sessions"`
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"`
Sessions []WardrivingSession `json:"sessions"`
StandardPayloadCount int `json:"standardPayloadCount"`
Anomalies []WardrivingAnomaly `json:"anomalies"`
}
// ─── Health ────────────────────────────────────────────────────────────────────
+83 -3
View File
@@ -1,6 +1,7 @@
package main
import (
"encoding/base64"
"encoding/json"
"net/http/httptest"
"testing"
@@ -284,6 +285,82 @@ func TestHandleWardrivingStats_Sessions(t *testing.T) {
}
}
// TestHandleWardrivingStats_Anomalies covers payload-anomaly detection:
// standard 7-byte tokens count toward StandardPayloadCount, non-standard
// lengths and undecodable base64 are grouped per sender into Anomalies,
// and messages with no "MM:" prefix at all (plain chat) are ignored
// entirely rather than polluting either bucket.
func TestHandleWardrivingStats_Anomalies(t *testing.T) {
srv, router := setupTestServer(t)
if _, err := srv.db.conn.Exec(`DELETE FROM transmissions`); err != nil {
t.Fatalf("clear transmissions: %v", err)
}
insertTx := func(hash, sender, text string, tsOffset time.Duration) {
ts := time.Now().UTC().Add(tsOffset).Format(time.RFC3339)
if _, 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, `{"sender":"`+sender+`","text":"`+text+`"}`,
); err != nil {
t.Fatalf("insert tx %s: %v", hash, err)
}
}
standardToken := base64.RawURLEncoding.EncodeToString([]byte{1, 2, 3, 4, 5, 6, 7})
longPayload := base64.RawURLEncoding.EncodeToString(make([]byte, 12))
insertTx("std1", "Alice", "MM:"+standardToken, -3*time.Hour)
insertTx("std2", "Bob", "MM:"+standardToken, -2*time.Hour)
// Suspect1: two messages with a consistent non-standard 12-byte payload.
insertTx("anom1", "Suspect1", "MM:"+longPayload, -90*time.Minute)
insertTx("anom2", "Suspect1", "MM:"+longPayload, -80*time.Minute)
// Suspect2: one genuinely undecodable payload (invalid base64 chars).
insertTx("anom3", "Suspect2", "MM:!!!not-valid-b64!!!", -70*time.Minute)
// Plain chat on the same channel — must be ignored entirely, not
// counted as standard or anomalous.
insertTx("chat1", "Eve", "hey is anyone home", -60*time.Minute)
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())
}
if resp.StandardPayloadCount != 2 {
t.Errorf("StandardPayloadCount = %d, want 2 (Alice + Bob)", resp.StandardPayloadCount)
}
if len(resp.Anomalies) != 2 {
t.Fatalf("Anomalies = %+v, want 2 senders (Suspect1, Suspect2)", resp.Anomalies)
}
// Sorted by MessageCount desc: Suspect1 (2 messages) before Suspect2 (1).
s1, s2 := resp.Anomalies[0], resp.Anomalies[1]
if s1.Sender != "Suspect1" || s1.MessageCount != 2 {
t.Errorf("Anomalies[0] = %+v, want {Suspect1, 2 messages}", s1)
}
if len(s1.PayloadBytes) != 1 || s1.PayloadBytes[0] != 12 {
t.Errorf("Suspect1 PayloadBytes = %v, want [12]", s1.PayloadBytes)
}
if s2.Sender != "Suspect2" || s2.MessageCount != 1 {
t.Errorf("Anomalies[1] = %+v, want {Suspect2, 1 message}", s2)
}
if len(s2.PayloadBytes) != 1 || s2.PayloadBytes[0] != -1 {
t.Errorf("Suspect2 PayloadBytes = %v, want [-1] (undecodable)", s2.PayloadBytes)
}
// Eve's plain-chat message must not appear anywhere in either bucket.
for _, a := range resp.Anomalies {
if a.Sender == "Eve" {
t.Error("Eve's plain-chat message (no MM: prefix) must not be counted as an anomaly")
}
}
}
// TestHandleWardrivingStats_InvalidWindow mirrors the existing scope-stats
// window validation.
func TestHandleWardrivingStats_InvalidWindow(t *testing.T) {
@@ -318,11 +395,14 @@ 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 || resp.SignalTimeSeries == nil || resp.Sessions == nil {
t.Errorf("expected empty (non-nil) slices, got TopSenders=%v EntryPoints=%v Observers=%v TimeSeries=%v SignalTimeSeries=%v Sessions=%v",
resp.TopSenders, resp.EntryPoints, resp.Observers, resp.TimeSeries, resp.SignalTimeSeries, resp.Sessions)
if resp.TopSenders == nil || resp.EntryPoints == nil || resp.Observers == nil || resp.TimeSeries == nil || resp.SignalTimeSeries == nil || resp.Sessions == nil || resp.Anomalies == nil {
t.Errorf("expected empty (non-nil) slices, got TopSenders=%v EntryPoints=%v Observers=%v TimeSeries=%v SignalTimeSeries=%v Sessions=%v Anomalies=%v",
resp.TopSenders, resp.EntryPoints, resp.Observers, resp.TimeSeries, resp.SignalTimeSeries, resp.Sessions, resp.Anomalies)
}
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)
}
if resp.StandardPayloadCount != 0 {
t.Errorf("StandardPayloadCount = %d, want 0", resp.StandardPayloadCount)
}
}
+31 -1
View File
@@ -5427,6 +5427,7 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
{ label: 'Avg SNR', value: (d.avgSnr != null ? d.avgSnr.toFixed(1) + ' dB' : '—'), note: null },
{ label: 'Avg RSSI', value: (d.avgRssi != null ? d.avgRssi.toFixed(1) + ' dBm' : '—'), note: null },
{ label: 'Sessions', value: (d.sessions || []).length.toLocaleString(), note: '15min+ gap starts a new one' },
{ label: 'Payload Anomalies', value: (d.anomalies || []).length.toLocaleString(), note: 'senders, non-standard "MM:" payload' },
].map(function(c) {
return '<div class="stat-card"><div class="stat-value">' + c.value + '</div>' +
'<div class="stat-label">' + c.label + '</div>' +
@@ -5599,6 +5600,32 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
'</table>';
}
function payloadBytesLabel(bytesList) {
return (bytesList || []).map(function(n) { return n === -1 ? 'undecodable' : (n + 'B'); }).join(', ');
}
// Payload anomalies — a heuristic, not a decode: MeshMapper's optional
// "Broadcast My Coordinates" mode uses an undocumented on-air byte
// format, so we only flag that a sender's payload length deviates from
// the standard 7-byte token, and show the raw hex for manual review —
// never an attempted lat/lon interpretation.
function anomaliesHtml(anomalies, standardCount) {
if (!anomalies || anomalies.length === 0) {
return '<p class="text-muted" style="font-size:0.85em">All ' + standardCount.toLocaleString() + ' "MM:" wardriving messages in this window used the standard 7-byte anonymous token — no non-standard payloads detected.</p>';
}
var rows = anomalies.map(function(a) {
return '<tr><td>' + esc(a.sender) + '</td>' +
'<td>' + a.messageCount.toLocaleString() + '</td>' +
'<td>' + payloadBytesLabel(a.payloadBytes) + '</td>' +
'<td><code>' + esc(a.sampleHex || '—') + '</code></td>' +
'<td>' + (typeof timeAgo === 'function' ? timeAgo(a.lastSeen) : a.lastSeen) + '</td></tr>';
}).join('');
return '<table class="data-table analytics-table">' +
'<thead><tr><th>Sender</th><th>Messages</th><th>Payload Length</th><th>Sample Payload (hex)</th><th>Last Seen</th></tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
}
function attachWindowButtons() {
el.querySelectorAll('[data-wdwin]').forEach(function(btn) {
btn.addEventListener('click', function() {
@@ -5634,7 +5661,10 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _stopForeignTraf
'<div id="wardrivingSignal" style="display:grid;grid-template-columns:1fr 1fr;gap:16px">' +
'<div><div class="text-muted" style="font-size:0.8em;margin-bottom:4px">Avg SNR (dB)</div>' + signalChartHtml(d.signalTimeSeries, 'avgSnr', 'var(--accent)', 'Average SNR over time') + '</div>' +
'<div><div class="text-muted" style="font-size:0.8em;margin-bottom:4px">Avg RSSI (dBm)</div>' + signalChartHtml(d.signalTimeSeries, 'avgRssi', 'var(--warning, #f39c12)', 'Average RSSI over time') + '</div>' +
'</div>';
'</div>' +
'<h4 style="margin:24px 0 4px">Payload Anomalies (Coordinate-Broadcast Watch)</h4>' +
'<p class="text-muted" style="margin:0 0 8px;font-size:0.85em">MeshMapper\'s default wardriving ping is a 7-byte anonymous token. A different payload length is a candidate signal that a sender has enabled MeshMapper\'s optional "Broadcast My Coordinates" mode — its on-air byte format is undocumented, so we show the raw hex rather than guessing at lat/lon.</p>' +
'<div id="wardrivingAnomalies">' + anomaliesHtml(d.anomalies, d.standardPayloadCount || 0) + '</div>';
} catch (err) {
body = '<div class="text-center" style="color:var(--status-red);padding:20px">Failed to load wardriving stats: ' + esc(String(err)) + '</div>';
}
+29
View File
@@ -142,6 +142,10 @@ function makeWardrivingResponse(overrides) {
{ sender: 'Bob', startTime: '2026-07-20T08:30:00Z', endTime: '2026-07-20T08:30:00Z', durationMinutes: 0, messageCount: 1, entryPointCount: 1, observerCount: 1 },
{ sender: 'Alice', startTime: '2026-07-20T08:00:00Z', endTime: '2026-07-20T08:05:00Z', durationMinutes: 5, messageCount: 1, entryPointCount: 2, observerCount: 1 },
],
standardPayloadCount: 3,
anomalies: [
{ sender: 'Suspect1', messageCount: 2, payloadBytes: [12], sampleHex: 'aabbccdd', lastSeen: '2026-07-20T09:00:00Z' },
],
}, overrides);
}
@@ -170,6 +174,29 @@ function makeApiStub(wardrivingResp, resolveHopsResp) {
assert.ok(el.innerHTML.includes('Observers Reached'), 'Observers Reached card label should render');
assert.ok(el.innerHTML.includes('5.5 dB'), 'Avg SNR card should show the API-provided average');
assert.ok(el.innerHTML.includes('-72.5 dBm'), 'Avg RSSI card should show the API-provided average');
assert.ok(el.innerHTML.includes('<div class="stat-value">1</div><div class="stat-label">Payload Anomalies</div>'), 'Payload Anomalies card should show the anomalous-sender count');
});
await testAsync('Payload Anomalies table lists non-standard senders with hex sample, hides interpreted coordinates', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse()));
const el = fakeEl();
await ctx.window._analyticsRenderWardrivingTab(el);
const startIdx = el.innerHTML.indexOf('id="wardrivingAnomalies"');
assert.ok(startIdx > -1, 'Payload Anomalies section should render');
const section = el.innerHTML.slice(startIdx);
assert.ok(section.includes('Suspect1'), 'the anomalous sender should be listed');
assert.ok(section.includes('12B'), 'the non-standard payload length should render');
assert.ok(section.includes('aabbccdd'), 'the sample hex dump should render for manual inspection');
assert.ok(!/-?\d+\.\d+,\s*-?\d+\.\d+/.test(section), 'must never render anything that looks like an interpreted lat/lon coordinate pair');
});
await testAsync('Payload Anomalies shows a reassuring message when nothing is anomalous', async () => {
const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({ anomalies: [], standardPayloadCount: 42 })));
const el = fakeEl();
await ctx.window._analyticsRenderWardrivingTab(el);
const startIdx = el.innerHTML.indexOf('id="wardrivingAnomalies"');
const section = el.innerHTML.slice(startIdx);
assert.ok(section.includes('42') && section.includes('no non-standard payloads detected'), 'should report the standard-payload count and a clean bill of health');
});
await testAsync('Signal Quality Trends renders both SNR and RSSI charts', async () => {
@@ -247,6 +274,7 @@ function makeApiStub(wardrivingResp, resolveHopsResp) {
const ctx = makeAnalyticsSandbox(makeApiStub(makeWardrivingResponse({
totalMessages: 0, topSenders: [], entryPoints: [], observers: [], timeSeries: [],
signalTimeSeries: [], avgSnr: null, avgRssi: null, sessions: [],
anomalies: [], standardPayloadCount: 0,
})));
const el = fakeEl();
await ctx.window._analyticsRenderWardrivingTab(el);
@@ -257,6 +285,7 @@ function makeApiStub(wardrivingResp, resolveHopsResp) {
assert.ok(el.innerHTML.includes('Insufficient data points to chart'), 'signal chart empty state should show');
assert.ok(el.innerHTML.includes('<div class="stat-value">—</div><div class="stat-label">Avg SNR</div>'), 'Avg SNR card should show a dash when there is no signal data');
assert.ok(el.innerHTML.includes('<div class="stat-value">—</div><div class="stat-label">Avg RSSI</div>'), 'Avg RSSI card should show a dash when there is no signal data');
assert.ok(el.innerHTML.includes('no non-standard payloads detected'), 'anomalies empty state should show');
});
await testAsync('rendering registers a real interval, and stop() actually clears it (not a no-op)', async () => {