diff --git a/README.md b/README.md
index 3d25ce6..0adf230 100644
--- a/README.md
+++ b/README.md
@@ -231,6 +231,10 @@ Open `http://192.168.1.133:8080` in your browser.
- **RSSI history chart** — opens from any Networks row. Shows signal strength over time per node (1h / 2h / 6h / 24h selectable). Each node gets its own coloured line. Useful for spotting interference patterns, seeing how signal fluctuates by time of day, and comparing which node consistently hears a given AP better. Updates live as new scans arrive.
- **Cross-node** — side-by-side per-node RSSI for every AP. Shows which node is physically closer to each network. Filter to multi-node only to focus on confirmed cross-node observations.
- **Clients** — probe request data: unique MACs, vendor (OUI lookup), what SSIDs they're searching for, signal, which nodes saw them.
+- **Presence** — three sections driven entirely by probe data:
+ - *Present Now* — real (non-randomized) MACs seen 2+ times in the last hour. Filters out the city-center noise of transient devices.
+ - *New Arrivals* — devices and networks first seen in the last 24 hours, split into two side-by-side tables.
+ - *Regulars* — devices and networks seen on 2 or more distinct calendar days. Takes at least two days of data to populate.
- **Node detail** — click a node in the sidebar for stats: total events, unique SSIDs/BSSIDs, RSSI range, first/last seen, uptime, free heap, AP signal, last 100 events.
### Sidebar
@@ -240,7 +244,7 @@ A node is considered **online** if a heartbeat was received within the last 30 s
### Live updates
-SSE stream pushes new beacon events as they arrive. The feed updates immediately. All other views (sidebar, networks, chart, cross-node) refresh on a 2-second debounce so a single scan burst doesn't flood the server with requests.
+SSE stream pushes new beacon events as they arrive. The feed updates immediately. All other views (sidebar, networks, chart, cross-node, presence) refresh on a 2-second debounce so a single scan burst doesn't flood the server with requests.
---
@@ -277,7 +281,10 @@ The `oui.txt` file is the IEEE public OUI database (39,171 entries as of downloa
- [x] Deployed to Orange Pi as systemd services (runs 24/7)
- [x] All three nodes sending to Orange Pi, confirmed live
- [x] Node heartbeat every 10s — uptime, free heap, AP signal, drives online/offline status
+- [x] RSSI history chart per network (canvas, per-node coloured lines, 1h/2h/6h/24h range)
+- [x] Presence tab — Present Now, New Arrivals, Regulars
- [ ] Scan interval control from dashboard
+- [ ] DB pruning / retention policy (events.db grows indefinitely)
---
diff --git a/coordinator/dashboard.py b/coordinator/dashboard.py
index 9cb7c41..5b28884 100644
--- a/coordinator/dashboard.py
+++ b/coordinator/dashboard.py
@@ -170,14 +170,18 @@ def build_clients() -> list[dict]:
GROUP BY src_mac
ORDER BY times_seen DESC
""")
- # Attach probed SSIDs and OUI vendor info to each row
+ # Fetch all probed SSIDs in one query, group in Python — avoids N+1
+ ssid_rows = query("""
+ SELECT DISTINCT src_mac, ssid FROM probe_events
+ WHERE src_mac IS NOT NULL AND ssid IS NOT NULL AND ssid != ''
+ ORDER BY src_mac, ssid
+ """)
+ ssids_by_mac: dict = {}
+ for s in ssid_rows:
+ ssids_by_mac.setdefault(s["src_mac"], []).append(s["ssid"])
+
for r in rows:
- ssid_rows = query("""
- SELECT DISTINCT ssid FROM probe_events
- WHERE src_mac = ? AND ssid IS NOT NULL AND ssid != ''
- ORDER BY ssid
- """, (r["src_mac"],))
- r["probed_ssids"] = [s["ssid"] for s in ssid_rows]
+ r["probed_ssids"] = ssids_by_mac.get(r["src_mac"], [])
vendor, randomized = oui_lookup(r["src_mac"])
r["vendor"] = vendor
r["randomized"] = randomized
@@ -237,6 +241,119 @@ def build_cross_node() -> dict:
return {"node_ids": node_ids, "networks": result}
+def build_presence() -> dict:
+ now = datetime.datetime.utcnow()
+ cutoff_1h = (now - datetime.timedelta(hours=1)).isoformat(timespec="seconds")
+ cutoff_24h = (now - datetime.timedelta(hours=24)).isoformat(timespec="seconds")
+
+ # ── Present now: real MACs seen 2+ times in the last hour ────────────────
+ present_rows = query("""
+ SELECT src_mac,
+ COUNT(*) AS times_seen,
+ MAX(rssi) AS best_rssi,
+ ROUND(AVG(rssi), 1) AS avg_rssi,
+ MAX(received_at) AS last_seen,
+ COUNT(DISTINCT node_id) AS node_count
+ FROM probe_events
+ WHERE src_mac IS NOT NULL AND received_at > ?
+ GROUP BY src_mac
+ HAVING COUNT(*) > 1
+ ORDER BY last_seen DESC
+ """, (cutoff_1h,))
+ present = []
+ for r in present_rows:
+ vendor, randomized = oui_lookup(r["src_mac"])
+ if not randomized:
+ r["vendor"] = vendor
+ present.append(r)
+
+ # ── New arrivals: first seen in last 24h ─────────────────────────────────
+ new_mac_rows = query("""
+ SELECT src_mac,
+ MIN(received_at) AS first_seen,
+ MAX(rssi) AS best_rssi,
+ COUNT(*) AS times_seen,
+ COUNT(DISTINCT node_id) AS node_count
+ FROM probe_events
+ WHERE src_mac IS NOT NULL
+ GROUP BY src_mac
+ HAVING MIN(received_at) > ?
+ ORDER BY first_seen DESC
+ LIMIT 100
+ """, (cutoff_24h,))
+ new_macs = []
+ for r in new_mac_rows:
+ vendor, randomized = oui_lookup(r["src_mac"])
+ r["vendor"] = vendor
+ r["randomized"] = randomized
+ new_macs.append(r)
+
+ new_networks = query("""
+ SELECT bssid,
+ MAX(ssid) AS ssid,
+ MIN(received_at) AS first_seen,
+ MAX(rssi) AS best_rssi,
+ MAX(channel) AS channel,
+ MAX(encryption) AS encryption,
+ COUNT(DISTINCT node_id) AS node_count
+ FROM beacon_events
+ WHERE bssid IS NOT NULL
+ GROUP BY bssid
+ HAVING MIN(received_at) > ?
+ ORDER BY first_seen DESC
+ LIMIT 100
+ """, (cutoff_24h,))
+
+ # ── Regulars: seen on 2+ distinct calendar days ───────────────────────────
+ reg_mac_rows = query("""
+ SELECT src_mac,
+ COUNT(DISTINCT date(received_at)) AS days_seen,
+ MIN(received_at) AS first_seen,
+ MAX(received_at) AS last_seen,
+ MAX(rssi) AS best_rssi,
+ COUNT(*) AS times_seen,
+ COUNT(DISTINCT node_id) AS node_count
+ FROM probe_events
+ WHERE src_mac IS NOT NULL
+ GROUP BY src_mac
+ HAVING COUNT(DISTINCT date(received_at)) >= 2
+ ORDER BY days_seen DESC, times_seen DESC
+ LIMIT 50
+ """)
+ reg_macs = []
+ for r in reg_mac_rows:
+ vendor, randomized = oui_lookup(r["src_mac"])
+ r["vendor"] = vendor
+ r["randomized"] = randomized
+ reg_macs.append(r)
+
+ reg_networks = query("""
+ SELECT bssid,
+ MAX(ssid) AS ssid,
+ COUNT(DISTINCT date(received_at)) AS days_seen,
+ MIN(received_at) AS first_seen,
+ MAX(received_at) AS last_seen,
+ MAX(rssi) AS best_rssi,
+ MAX(channel) AS channel,
+ MAX(encryption) AS encryption,
+ COUNT(DISTINCT node_id) AS node_count
+ FROM beacon_events
+ WHERE bssid IS NOT NULL
+ GROUP BY bssid
+ HAVING COUNT(DISTINCT date(received_at)) >= 2
+ ORDER BY days_seen DESC, best_rssi DESC
+ LIMIT 50
+ """)
+
+ return {
+ "present": present,
+ "new_macs": new_macs,
+ "new_networks": new_networks,
+ "reg_macs": reg_macs,
+ "reg_networks": reg_networks,
+ }
+
+
def build_node_detail(node_id: str) -> dict | None:
rows = query("""
SELECT
@@ -349,6 +466,11 @@ async def api_cross_node():
return build_cross_node()
+@app.get("/api/presence")
+async def api_presence():
+ return build_presence()
+
+
@app.get("/api/rssi-history")
async def api_rssi_history(bssid: str, hours: int = 2):
hours = min(max(hours, 1), 48)
diff --git a/coordinator/static/dashboard.css b/coordinator/static/dashboard.css
index 4f3c4f6..1e87e2d 100644
--- a/coordinator/static/dashboard.css
+++ b/coordinator/static/dashboard.css
@@ -360,6 +360,25 @@ thead th.sort-active::after { content: ' ' attr(data-arrow); }
letter-spacing: 0.06em;
}
+/* ── Presence ── */
+.presence-split {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+.presence-sub {
+ font-size: 10px;
+ color: var(--text-muted);
+ margin-left: auto;
+}
+.presence-sub-label {
+ font-size: 9px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ margin-bottom: 6px;
+}
+
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: var(--bg); }
diff --git a/coordinator/static/dashboard.js b/coordinator/static/dashboard.js
index 9db39b3..53c6c34 100644
--- a/coordinator/static/dashboard.js
+++ b/coordinator/static/dashboard.js
@@ -8,6 +8,7 @@ let networksData = [];
let netSort = { col: 'times_seen', dir: 'desc' };
let crossNodeData = { node_ids: [], networks: [] };
let clientsData = [];
+let presenceData = {};
let chartBssid = null;
let chartHours = 2;
@@ -316,6 +317,126 @@ function renderClientsTable() {
}).join('');
}
+// ── Presence view ─────────────────────────────────────────────────────────────
+async function fetchPresence() {
+ presenceData = await fetch('/api/presence').then(r => r.json());
+ renderPresenceView();
+}
+
+function renderPresenceView() {
+ const p = presenceData;
+
+ // Present Now
+ const presentTbody = document.getElementById('present-tbody');
+ const presentEmpty = document.getElementById('present-empty');
+ const presentCount = document.getElementById('present-count');
+ const present = p.present || [];
+ presentCount.textContent = `${present.length} devices`;
+ if (!present.length) {
+ presentTbody.innerHTML = '';
+ presentEmpty.style.display = 'block';
+ } else {
+ presentEmpty.style.display = 'none';
+ presentTbody.innerHTML = present.map(r => {
+ const nodeCls = r.node_count > 1 ? 'nodes-multi' : 'muted';
+ return `