diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f12b077..472a174 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -490,6 +490,10 @@ dashboard: Access at `http://:8080`. Bind to `127.0.0.1` to restrict to local access only. +### Coverage View (map) + +On the **Dashboard → Node Map**, open the Leaflet layer control (top-right) and enable **Coverage View**. Plotted nodes with GPS show RSSI-colored circles (green/cyan/yellow/red by average signal). Circle size reflects recent packet volume (confidence). Nodes without coordinates appear in the **unplotted** count beside the map title. Click any marker or coverage circle to open the node drawer. **Topology Links** remains a separate overlay and can be used together with coverage. + --- ## Device Identity diff --git a/frontend/css/map_coverage.css b/frontend/css/map_coverage.css new file mode 100644 index 0000000..a5c78a1 --- /dev/null +++ b/frontend/css/map_coverage.css @@ -0,0 +1,35 @@ +/* RSSI coverage circles on the dashboard Leaflet map (PR 06). */ + +.map-unplotted { + margin-left: auto; + font-size: 0.7rem; + font-weight: 500; + color: var(--text-muted, #94a3b8); + padding: 0.15rem 0.5rem; + border-radius: var(--radius, 6px); + border: 1px solid var(--border, #334155); + background: var(--bg-elevated, rgba(15, 23, 42, 0.6)); + white-space: nowrap; +} + +.map-unplotted--visible { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.panel__header--map { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.coverage-circle { + pointer-events: auto; +} + +.coverage-circle--excellent { stroke: var(--accent-green); fill: var(--accent-green); } +.coverage-circle--good { stroke: var(--accent-cyan); fill: var(--accent-cyan); } +.coverage-circle--fair { stroke: var(--accent-amber); fill: var(--accent-amber); } +.coverage-circle--poor { stroke: var(--accent-red); fill: var(--accent-red); } +.coverage-circle--unknown { stroke: var(--text-muted); fill: var(--text-muted); } diff --git a/frontend/index.html b/frontend/index.html index 239398c..7111b16 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,6 +13,7 @@ + @@ -321,7 +322,10 @@
-
Node Map
+
+ Node Map + +
diff --git a/frontend/js/app.js b/frontend/js/app.js index b6b237e..a7a45b8 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -150,6 +150,11 @@ document.addEventListener('DOMContentLoaded', async () => { }); } + nodeMap.setOnNodeClick(async (node) => { + if (backdrop) backdrop.classList.add('nd-backdrop--visible'); + await nodeDrawer.open(node); + }); + const origOpen = nodeDrawer.open.bind(nodeDrawer); nodeDrawer.open = async (node) => { if (backdrop) backdrop.classList.add('nd-backdrop--visible'); diff --git a/frontend/js/components/node_map.js b/frontend/js/components/node_map.js index c8a88af..230e260 100644 --- a/frontend/js/components/node_map.js +++ b/frontend/js/components/node_map.js @@ -47,7 +47,12 @@ class NodeMap { this._topologyLayer = L.layerGroup(); this._topologyVisible = false; + this._coverageLayer = L.layerGroup(); + this._coverageVisible = false; + this._coverageNodes = {}; this._focusLine = null; + this._onNodeClick = null; + this._unplottedEl = document.getElementById('map-unplotted-count'); this._markerGroup = L.markerClusterGroup({ maxClusterRadius: 50, @@ -68,7 +73,10 @@ class NodeMap { }); this._map.addLayer(this._markerGroup); - const overlays = { 'Topology Links': this._topologyLayer }; + const overlays = { + 'Coverage View': this._coverageLayer, + 'Topology Links': this._topologyLayer, + }; L.control.layers(null, overlays, { position: 'topright', collapsed: true }).addTo(this._map); this._map.on('overlayadd', (e) => { @@ -76,11 +84,18 @@ class NodeMap { this._topologyVisible = true; this._loadTopology(); } + if (e.layer === this._coverageLayer) { + this._coverageVisible = true; + this._loadCoverage(); + } }); this._map.on('overlayremove', (e) => { if (e.layer === this._topologyLayer) { this._topologyVisible = false; } + if (e.layer === this._coverageLayer) { + this._coverageVisible = false; + } }); this._initialized = true; @@ -181,9 +196,29 @@ class NodeMap { this._hasFitBounds = true; } + this._refreshMapMeta(); + if (this._topologyVisible) { this._loadTopology(); } + if (this._coverageVisible) { + this._loadCoverage(); + } + } + + async _refreshMapMeta() { + try { + const res = await fetch('/api/nodes/coverage?limit=1'); + if (!res.ok) return; + const data = await res.json(); + this._updateUnplottedCount(data.unplotted_count); + } catch (_e) { + /* best-effort sidebar count */ + } + } + + setOnNodeClick(callback) { + this._onNodeClick = typeof callback === 'function' ? callback : null; } _addDeviceMarker(device) { @@ -268,10 +303,18 @@ class NodeMap { `Last heard: ${lastHeard}` ); + marker.on('click', () => this._handleNodeClick(n)); + this._markerGroup.addLayer(marker); this._markers[n.node_id] = marker; } + _handleNodeClick(node) { + if (!this._onNodeClick) return; + const full = (this._lastNodes || []).find((row) => row.node_id === node.node_id) || node; + this._onNodeClick(full); + } + _formatRelativeTime(timestamp) { if (!timestamp) return 'unknown'; const t = new Date(timestamp).getTime(); @@ -404,6 +447,89 @@ class NodeMap { }, 200); } + _coverageColor(quality) { + const root = getComputedStyle(document.documentElement); + const token = (name, fallback) => root.getPropertyValue(name).trim() || fallback; + const colors = { + excellent: token('--accent-green', '#00e5a0'), + good: token('--accent-cyan', '#06b6d4'), + fair: token('--accent-amber', '#f59e0b'), + poor: token('--accent-red', '#ef4444'), + unknown: token('--text-muted', '#64748b'), + }; + return colors[quality] || colors.unknown; + } + + _coverageRadiusMeters(packetCount) { + const min = 250; + const max = 4000; + const count = Math.max(1, Number(packetCount) || 1); + const normalized = Math.min(1, Math.log10(count + 1) / 3); + return min + normalized * (max - min); + } + + _updateUnplottedCount(count) { + if (!this._unplottedEl) return; + const n = Number(count) || 0; + if (n <= 0) { + this._unplottedEl.hidden = true; + this._unplottedEl.textContent = ''; + return; + } + this._unplottedEl.hidden = false; + this._unplottedEl.classList.add('map-unplotted--visible'); + this._unplottedEl.textContent = `${n} unplotted`; + this._unplottedEl.title = `${n} node${n === 1 ? '' : 's'} without GPS`; + } + + async _loadCoverage() { + try { + const res = await fetch('/api/nodes/coverage'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + const plotted = Array.isArray(data.plotted) ? data.plotted : []; + this._coverageLayer.clearLayers(); + this._coverageNodes = {}; + this._updateUnplottedCount(data.unplotted_count); + + for (const row of plotted) { + const lat = row.latitude; + const lon = row.longitude; + if (lat == null || lon == null) continue; + + const quality = row.rssi_quality || 'unknown'; + const color = this._coverageColor(quality); + const packets = row.recent_packet_count ?? row.packet_count ?? 1; + const radius = this._coverageRadiusMeters(packets); + const rssi = row.avg_rssi ?? row.latest_rssi; + const rssiLabel = rssi != null ? `${Number(rssi).toFixed(0)} dBm` : '--'; + + const circle = L.circle([lat, lon], { + radius, + color, + fillColor: color, + fillOpacity: 0.18, + weight: 1.5, + className: `coverage-circle coverage-circle--${quality}`, + }); + + const name = row.display_name || row.node_id || '--'; + circle.bindPopup( + `${this._esc(name)}
` + + `Avg RSSI: ${rssiLabel}
` + + `Packets (${data.window_hours || 168}h): ${packets}
` + + `Coverage radius: ~${Math.round(radius)} m` + ); + circle.on('click', () => this._handleNodeClick(row)); + + this._coverageLayer.addLayer(circle); + this._coverageNodes[row.node_id] = row; + } + } catch (e) { + console.error('Coverage load failed:', e); + } + } + async _loadTopology() { try { const res = await fetch('/api/analytics/topology'); diff --git a/src/api/routes/nodes.py b/src/api/routes/nodes.py index 7a28d8b..ab50652 100644 --- a/src/api/routes/nodes.py +++ b/src/api/routes/nodes.py @@ -52,6 +52,15 @@ async def network_summary(): return await _network_mapper.get_network_summary() +@router.get("/coverage") +async def coverage_data( + limit: int = Query(500, ge=1, le=2000), + hours: float = Query(168, ge=1, le=720), +): + """RSSI coverage circles for nodes with GPS (JOIN nodes + packet aggregates).""" + return await _node_repo.get_coverage_data(limit=limit, hours=hours) + + @router.get("/{node_id}/metrics_history") async def metrics_history( node_id: str, diff --git a/src/storage/node_repository.py b/src/storage/node_repository.py index d9d2998..d66683c 100644 --- a/src/storage/node_repository.py +++ b/src/storage/node_repository.py @@ -1,14 +1,24 @@ from __future__ import annotations import logging -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Optional -from datetime import timedelta - from src.models.node import Node from src.storage.database import DatabaseManager + +def _rssi_quality(rssi: float | None) -> str: + if rssi is None: + return "unknown" + if rssi > -80: + return "excellent" + if rssi > -95: + return "good" + if rssi > -110: + return "fair" + return "poor" + logger = logging.getLogger(__name__) # Rows created from corrupted RX (no decoded NodeInfo). Matches COMMON-ERRORS @@ -153,6 +163,77 @@ def _enrich_row(row: dict) -> dict: d["latest_hops"] = max(0, hop_start - hop_limit) return d + async def get_coverage_data( + self, limit: int = 500, hours: float = 168, + ) -> dict: + """Nodes with GPS plus packet RSSI aggregates for the coverage map.""" + since = ( + datetime.now(timezone.utc) - timedelta(hours=hours) + ).isoformat() + unplotted_row = await self._db.fetch_one( + """ + SELECT COUNT(*) AS cnt FROM nodes + WHERE latitude IS NULL OR longitude IS NULL + """, + ) + total_row = await self._db.fetch_one("SELECT COUNT(*) AS cnt FROM nodes") + rows = await self._db.fetch_all( + """ + SELECT n.*, + p.rssi AS latest_rssi, + p.snr AS latest_snr, + agg.recent_packet_count, + agg.avg_rssi + FROM nodes n + LEFT JOIN ( + SELECT source_id, rssi, snr, + ROW_NUMBER() OVER ( + PARTITION BY source_id ORDER BY timestamp DESC + ) AS rn + FROM packets + WHERE rssi IS NOT NULL + ) p ON p.source_id = n.node_id AND p.rn = 1 + LEFT JOIN ( + SELECT source_id, + COUNT(*) AS recent_packet_count, + AVG(rssi) AS avg_rssi + FROM packets + WHERE rssi IS NOT NULL AND timestamp >= ? + GROUP BY source_id + ) agg ON agg.source_id = n.node_id + WHERE n.latitude IS NOT NULL AND n.longitude IS NOT NULL + ORDER BY n.last_heard DESC + LIMIT ? + """, + (since, limit), + ) + plotted = [] + for row in rows: + node = self._row_to_node(row) + avg_rssi = row.get("avg_rssi") + latest_rssi = row.get("latest_rssi") + signal_rssi = avg_rssi if avg_rssi is not None else latest_rssi + plotted.append({ + "node_id": node.node_id, + "display_name": node.display_name, + "latitude": node.latitude, + "longitude": node.longitude, + "protocol": node.protocol, + "packet_count": node.packet_count, + "recent_packet_count": int(row.get("recent_packet_count") or 0), + "avg_rssi": round(float(avg_rssi), 1) if avg_rssi is not None else None, + "latest_rssi": latest_rssi, + "rssi_quality": _rssi_quality(signal_rssi), + "last_heard": node.last_heard.isoformat(), + }) + return { + "plotted": plotted, + "plotted_count": len(plotted), + "unplotted_count": int(unplotted_row["cnt"]) if unplotted_row else 0, + "total_nodes": int(total_row["cnt"]) if total_row else 0, + "window_hours": hours, + } + async def increment_packet_count(self, node_id: str) -> None: await self._db.execute( "UPDATE nodes SET packet_count = packet_count + 1, last_heard = ? WHERE node_id = ?", diff --git a/tests/test_node_coverage.py b/tests/test_node_coverage.py new file mode 100644 index 0000000..9c3b4d6 --- /dev/null +++ b/tests/test_node_coverage.py @@ -0,0 +1,112 @@ +"""Coverage map aggregation for GET /api/nodes/coverage.""" + +from __future__ import annotations + +import asyncio +import unittest +from datetime import datetime, timedelta, timezone + +from src.models.node import Node +from src.storage.database import DatabaseManager +from src.storage.node_repository import NodeRepository + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +class TestNodeCoverage(unittest.TestCase): + def setUp(self) -> None: + self.db = DatabaseManager(":memory:") + _run(self.db.connect()) + self.repo = NodeRepository(self.db) + + def tearDown(self) -> None: + _run(self.db.disconnect()) + + def _insert_packet( + self, + packet_id: str, + source_id: str, + rssi: float, + *, + hours_ago: float = 1, + ) -> None: + ts = ( + datetime.now(timezone.utc) - timedelta(hours=hours_ago) + ).isoformat() + _run( + self.db.execute( + """ + INSERT INTO packets ( + packet_id, source_id, destination_id, protocol, + packet_type, hop_limit, hop_start, channel_hash, + want_ack, via_mqtt, relay_node, decrypted, + rssi, snr, capture_source, timestamp + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + packet_id, source_id, "ffffffff", "meshtastic", "text", + 3, 3, 8, 0, 0, 0, 1, + rssi, 5.0, "concentrator", ts, + ), + ) + ) + + def test_plotted_nodes_include_rssi_aggregates(self) -> None: + _run(self.repo.upsert(Node( + node_id="aaaa0001", + long_name="Tower", + latitude=40.0, + longitude=-105.0, + packet_count=12, + ))) + _run(self.repo.upsert(Node( + node_id="bbbb0002", + long_name="No GPS", + packet_count=3, + ))) + self._insert_packet("p1", "aaaa0001", -88.0) + self._insert_packet("p2", "aaaa0001", -92.0, hours_ago=2) + + data = _run(self.repo.get_coverage_data(hours=168)) + + self.assertEqual(data["plotted_count"], 1) + self.assertEqual(data["unplotted_count"], 1) + self.assertEqual(data["total_nodes"], 2) + plotted = data["plotted"][0] + self.assertEqual(plotted["node_id"], "aaaa0001") + self.assertEqual(plotted["rssi_quality"], "good") + self.assertEqual(plotted["recent_packet_count"], 2) + self.assertAlmostEqual(plotted["avg_rssi"], -90.0, places=1) + + def test_unplotted_count_matches_nodes_without_coordinates(self) -> None: + now = datetime.now(timezone.utc) + _run(self.repo.upsert(Node( + node_id="withgps", + latitude=39.0, + longitude=-98.0, + last_heard=now, + ))) + _run(self.repo.upsert(Node( + node_id="nogps1", + last_heard=now, + ))) + _run(self.repo.upsert(Node( + node_id="nogps2", + latitude=40.0, + longitude=None, + last_heard=now, + ))) + + data = _run(self.repo.get_coverage_data()) + self.assertEqual(data["unplotted_count"], 2) + self.assertEqual(data["plotted_count"], 1) + + +if __name__ == "__main__": + unittest.main()