diff --git a/TODO.md b/TODO.md index 7e5b79e..2f072f7 100644 --- a/TODO.md +++ b/TODO.md @@ -37,6 +37,7 @@ - [x] WebSocket real-time sync (instead of polling) - [ ] Automatic peer discovery via mDNS/Bonjour - [x] Sync RSSI history for trilateration +- [x] Master dashboard: view peer node data without redirect - [ ] Web UI for peer management - [ ] Sync conflict resolution UI @@ -248,6 +249,7 @@ - [x] Termux/Android environment detection with prerequisite checks - [x] Multi-scanner trilateration for device positioning - [x] Signal coverage heat map visualization +- [x] Multi-node master dashboard (view peer data in single UI) --- diff --git a/src/rf_mapper/config.py b/src/rf_mapper/config.py index d69b509..bfa4341 100644 --- a/src/rf_mapper/config.py +++ b/src/rf_mapper/config.py @@ -28,6 +28,7 @@ class ScannerConfig: latitude: float | None = None # Scanner position (falls back to gps.latitude) longitude: float | None = None # Scanner position (falls back to gps.longitude) floor: int | None = None # Scanner's floor (falls back to building.current_floor) + is_master: bool = False # Master node can view other nodes' data in dashboard # Scanning configuration wifi_interface: str = "wlan0" @@ -179,6 +180,7 @@ class Config: latitude=data["scanner"].get("latitude", config.scanner.latitude), longitude=data["scanner"].get("longitude", config.scanner.longitude), floor=data["scanner"].get("floor", config.scanner.floor), + is_master=data["scanner"].get("is_master", config.scanner.is_master), # Scanning configuration wifi_interface=data["scanner"].get("wifi_interface", config.scanner.wifi_interface), bt_scan_timeout=data["scanner"].get("bt_scan_timeout", config.scanner.bt_scan_timeout), @@ -330,6 +332,7 @@ class Config: "latitude": self.scanner.latitude, "longitude": self.scanner.longitude, "floor": self.scanner.floor, + "is_master": self.scanner.is_master, # Scanning configuration "wifi_interface": self.scanner.wifi_interface, "bt_scan_timeout": self.scanner.bt_scan_timeout, diff --git a/src/rf_mapper/web/app.py b/src/rf_mapper/web/app.py index 6d79a8e..5660f05 100644 --- a/src/rf_mapper/web/app.py +++ b/src/rf_mapper/web/app.py @@ -7,6 +7,7 @@ import time from datetime import datetime from pathlib import Path +import requests from flask import Flask, current_app, jsonify, render_template, request from flask_socketio import SocketIO, emit @@ -1453,11 +1454,13 @@ def create_app(config: Config | None = None) -> Flask: if not db: return jsonify({"error": "Database not enabled"}), 503 + rf_config = app.config["RF_CONFIG"] peers = db.get_peers() peer_sync = app.config.get("PEER_SYNC") return jsonify({ "this_scanner": app.config["SCANNER_IDENTITY"], + "is_master": rf_config.scanner.is_master, "peers": peers, "sync_status": peer_sync.get_status() if peer_sync else None }) @@ -1603,6 +1606,71 @@ def create_app(config: Config | None = None) -> Flask: "results": results }) + # ==================== Multi-Node Master Dashboard Proxy API ==================== + + def proxy_peer_request(scanner_id: str, path: str, method: str = "GET", **kwargs): + """Proxy a request to a peer node. + + Returns: + - Response tuple (jsonify(...), status_code) on error or peer response + - None if scanner_id matches local scanner (caller should use local handler) + """ + local_id = app.config.get("SCANNER_IDENTITY", {}).get("id") + if scanner_id == local_id: + return None # Signal to use local handler + + db = app.config.get("DATABASE") + peer = db.get_peer(scanner_id) if db else None + if not peer or not peer.get("url"): + return jsonify({"error": "Peer not found"}), 404 + + try: + url = f"{peer['url']}{path}" + resp = requests.request(method, url, timeout=15, **kwargs) + data = resp.json() + # Enrich response with source scanner info + data["_source_scanner"] = scanner_id + data["_source_position"] = { + "lat": peer.get("latitude"), + "lon": peer.get("longitude"), + "floor": peer.get("floor") + } + return jsonify(data) + except requests.RequestException as e: + return jsonify({"error": f"Peer unreachable: {e}"}), 503 + + @app.route("/api/node//latest") + def api_node_latest(scanner_id: str): + """Proxy: Get latest scan from a peer node.""" + result = proxy_peer_request(scanner_id, "/api/latest") + if result is None: + return api_latest_scan() + return result + + @app.route("/api/node//scan/bt", methods=["POST"]) + def api_node_scan_bt(scanner_id: str): + """Proxy: Trigger BT scan on a peer node.""" + result = proxy_peer_request(scanner_id, "/api/scan/bt", method="POST") + if result is None: + return api_scan_bt() + return result + + @app.route("/api/node//device/floors") + def api_node_device_floors(scanner_id: str): + """Proxy: Get device floors from a peer node.""" + result = proxy_peer_request(scanner_id, "/api/device/floors") + if result is None: + return api_device_floors() + return result + + @app.route("/api/node//positions/trilaterated") + def api_node_trilaterated(scanner_id: str): + """Proxy: Get trilaterated positions from a peer node.""" + result = proxy_peer_request(scanner_id, "/api/positions/trilaterated") + if result is None: + return api_trilaterated_positions() + return result + return app diff --git a/src/rf_mapper/web/static/css/style.css b/src/rf_mapper/web/static/css/style.css index 2533407..07cbc54 100644 --- a/src/rf_mapper/web/static/css/style.css +++ b/src/rf_mapper/web/static/css/style.css @@ -56,6 +56,44 @@ body { align-items: center; } +/* Node selector for master dashboard */ +#node-selector-container { + display: flex; + align-items: center; + gap: 0.5rem; + margin-right: 1rem; +} + +#node-selector-container.hidden { + display: none; +} + +#node-select { + background: var(--bg-primary); + color: var(--color-text); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 0.35rem 0.6rem; + font-size: 0.85rem; + cursor: pointer; + min-width: 140px; +} + +#node-select:hover { + border-color: var(--color-primary); +} + +#node-select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(0, 255, 136, 0.2); +} + +.node-status { + font-size: 0.9rem; + transition: color 0.3s; +} + /* Buttons */ .btn { background: var(--bg-tertiary); diff --git a/src/rf_mapper/web/static/js/app.js b/src/rf_mapper/web/static/js/app.js index 7c4b910..45c5629 100644 --- a/src/rf_mapper/web/static/js/app.js +++ b/src/rf_mapper/web/static/js/app.js @@ -31,6 +31,11 @@ let trilaterationEnabled = true; // Heat map state let heatMapEnabled = false; +// Multi-node master state +let isMasterNode = false; +let activeNode = 'local'; // 'local' or scanner_id +let activeNodeInfo = null; // { id, name, url, lat, lon, floor } + // Auto-scan state let autoScanEnabled = false; let autoScanPollInterval = null; @@ -132,6 +137,10 @@ let openPopupDeviceId = null; // Track which device popup is open // Initialize on DOM ready document.addEventListener('DOMContentLoaded', () => { + // Store original position for node switching + APP_CONFIG.originalLat = APP_CONFIG.defaultLat; + APP_CONFIG.originalLon = APP_CONFIG.defaultLon; + initMap(); initRadar(); initFloorSelector(); @@ -149,6 +158,9 @@ document.addEventListener('DOMContentLoaded', () => { // Initialize WebSocket connection initWebSocket(); + // Initialize master dashboard (checks if this node is master) + initMasterDashboard(); + // Start BT live tracking by default after a short delay setTimeout(() => { startLiveTracking(); @@ -198,6 +210,176 @@ function initWebSocket() { rfMapperWS.on('scanUpdate', (data) => { handleWebSocketScanUpdate(data); }); + + // Handle peer scan updates (from master dashboard peer connections) + rfMapperWS.on('peerScanUpdate', (data) => { + if (activeNode !== 'local') { + handleWebSocketScanUpdate(data); + } + }); +} + +// ========== Multi-Node Master Dashboard ========== + +// Initialize master dashboard (checks if this node is master and shows node selector) +async function initMasterDashboard() { + try { + const resp = await fetch('/api/peers'); + const data = await resp.json(); + + isMasterNode = data.is_master === true; + + if (isMasterNode && data.peers?.length > 0) { + document.getElementById('node-selector-container').classList.remove('hidden'); + populateNodeSelector(data.this_scanner, data.peers); + console.log('[Master] Dashboard enabled with', data.peers.length, 'peers'); + } + } catch (e) { + console.error('[Master] Init failed:', e); + } +} + +// Populate node selector dropdown with local and peer scanners +function populateNodeSelector(thisScanner, peers) { + const select = document.getElementById('node-select'); + select.innerHTML = ``; + + peers.forEach(peer => { + const opt = document.createElement('option'); + opt.value = peer.scanner_id; + opt.textContent = `📡 ${peer.name || peer.scanner_id}`; + opt.dataset.url = peer.url; + opt.dataset.lat = peer.latitude; + opt.dataset.lon = peer.longitude; + opt.dataset.floor = peer.floor || 0; + select.appendChild(opt); + }); +} + +// Switch to viewing a different node's data +async function switchNode(nodeId) { + const select = document.getElementById('node-select'); + const statusEl = document.getElementById('node-status'); + const wasLiveTracking = liveTrackingEnabled; + + // Stop current tracking and peer connection + if (wasLiveTracking) stopLiveTracking(); + if (activeNode !== 'local' && typeof rfMapperWS !== 'undefined') { + rfMapperWS.disconnectFromPeer(); + } + + activeNode = nodeId; + statusEl.textContent = '⏳'; + statusEl.style.color = '#fbbf24'; + + if (nodeId === 'local') { + activeNodeInfo = null; + // Restore local position + const localLat = APP_CONFIG.originalLat || APP_CONFIG.defaultLat; + const localLon = APP_CONFIG.originalLon || APP_CONFIG.defaultLon; + updateMapCenter(localLat, localLon); + + await loadLatestScan(); + await loadDevicePositions(); + await loadTrilateratedPositions(); + + // Reconnect local WebSocket + if (typeof rfMapperWS !== 'undefined') { + rfMapperWS.connect(); + } + + statusEl.textContent = '●'; + statusEl.style.color = '#4ade80'; + console.log('[Node] Switched to local'); + } else { + const opt = select.options[select.selectedIndex]; + activeNodeInfo = { + id: nodeId, + name: opt.textContent, + url: opt.dataset.url, + lat: parseFloat(opt.dataset.lat), + lon: parseFloat(opt.dataset.lon), + floor: parseInt(opt.dataset.floor) || 0 + }; + + // Center map on peer's position + updateMapCenter(activeNodeInfo.lat, activeNodeInfo.lon); + + try { + // Load peer's data via proxy endpoints + const [latestResp, floorsResp] = await Promise.all([ + fetch(`/api/node/${nodeId}/latest`), + fetch(`/api/node/${nodeId}/device/floors`) + ]); + + if (latestResp.ok) { + scanData = await latestResp.json(); + if (floorsResp.ok) { + const floorsData = await floorsResp.json(); + updateDeviceFloors(floorsData); + } + updateUI(); + + // Connect to peer's WebSocket for real-time updates + if (typeof rfMapperWS !== 'undefined' && activeNodeInfo.url) { + rfMapperWS.connectToPeer(activeNodeInfo.url); + } + + statusEl.textContent = '●'; + statusEl.style.color = '#4ade80'; + console.log('[Node] Switched to', nodeId); + } else { + throw new Error('Failed to load peer data'); + } + } catch (e) { + console.error(`[Node] Load failed:`, e); + statusEl.textContent = '○'; + statusEl.style.color = '#ef4444'; + } + } + + if (wasLiveTracking) startLiveTracking(); +} + +// Update device floors data from API response +function updateDeviceFloors(floorsData) { + if (floorsData.floors) { + // Update scanData devices with floor info + if (scanData) { + const floors = floorsData.floors; + (scanData.wifi_networks || []).forEach(net => { + if (floors[net.bssid] !== undefined) { + net.floor = floors[net.bssid]; + } + }); + (scanData.bluetooth_devices || []).forEach(dev => { + if (floors[dev.address] !== undefined) { + dev.floor = floors[dev.address]; + } + }); + } + } + if (floorsData.positions) { + manualPositions = floorsData.positions; + } + if (floorsData.sources) { + deviceSources = floorsData.sources; + } +} + +// Update map center position (for node switching) +function updateMapCenter(lat, lon) { + APP_CONFIG.defaultLat = lat; + APP_CONFIG.defaultLon = lon; + document.getElementById('lat-input').value = lat.toFixed(6); + document.getElementById('lon-input').value = lon.toFixed(6); + + if (map) { + map.setView([lat, lon], map.getZoom()); + } + if (map3d) { + map3d.flyTo({ center: [lon, lat], duration: 1000 }); + } } // Handle scan updates received via WebSocket @@ -2186,7 +2368,12 @@ async function performLiveBTScan() { if (!liveTrackingEnabled) return; try { - const response = await fetch('/api/scan/bt', { + // Use node-specific endpoint if viewing a peer + const endpoint = activeNode === 'local' + ? '/api/scan/bt' + : `/api/node/${activeNode}/scan/bt`; + + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' } }); diff --git a/src/rf_mapper/web/static/js/websocket.js b/src/rf_mapper/web/static/js/websocket.js index 1a5b094..f785af8 100644 --- a/src/rf_mapper/web/static/js/websocket.js +++ b/src/rf_mapper/web/static/js/websocket.js @@ -4,13 +4,17 @@ class RFMapperWS { constructor() { this.socket = null; + this.peerSocket = null; // For peer node connections (master dashboard) this.connected = false; this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.listeners = { scanUpdate: [], connected: [], - disconnected: [] + disconnected: [], + peerConnected: [], + peerDisconnected: [], + peerScanUpdate: [] }; } @@ -78,6 +82,59 @@ class RFMapperWS { } } + // Connect to a peer node's WebSocket (for master dashboard) + connectToPeer(peerUrl) { + this.disconnectFromPeer(); + + // Check if socket.io is loaded + if (typeof io === 'undefined') { + console.warn('[WS] socket.io not loaded, cannot connect to peer'); + return false; + } + + try { + this.peerSocket = io(peerUrl + '/ws/scan', { + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: 3, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000 + }); + + this.peerSocket.on('connect', () => { + console.log('[WS] Connected to peer:', peerUrl); + this._emit('peerConnected', peerUrl); + }); + + this.peerSocket.on('scan_update', (data) => { + this._emit('peerScanUpdate', data); + }); + + this.peerSocket.on('disconnect', (reason) => { + console.log('[WS] Peer disconnected:', reason); + this._emit('peerDisconnected', { url: peerUrl, reason }); + }); + + this.peerSocket.on('connect_error', (error) => { + console.warn('[WS] Peer connection error:', error.message); + }); + + return true; + } catch (e) { + console.error('[WS] Failed to connect to peer:', e); + return false; + } + } + + // Disconnect from peer node + disconnectFromPeer() { + if (this.peerSocket) { + this.peerSocket.disconnect(); + this.peerSocket = null; + console.log('[WS] Disconnected from peer'); + } + } + on(event, callback) { if (this.listeners[event]) { this.listeners[event].push(callback); diff --git a/src/rf_mapper/web/templates/index.html b/src/rf_mapper/web/templates/index.html index e8802f0..0c214ce 100644 --- a/src/rf_mapper/web/templates/index.html +++ b/src/rf_mapper/web/templates/index.html @@ -3,6 +3,12 @@ {% block title %}RF Mapper - WiFi & Bluetooth Signal Map{% endblock %} {% block header_controls %} + Ready