feat: add multi-node master dashboard
Enable a designated "master" node to view device data from any peer node within the same dashboard, without page redirects. - Add is_master config option to ScannerConfig - Add proxy endpoints /api/node/<id>/* for peer data - Add node selector dropdown UI (shown only on master) - Add peer WebSocket connection support for real-time updates - Live tracking uses node-specific scan endpoint - Map centers on selected peer's position Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2
TODO.md
2
TODO.md
@@ -37,6 +37,7 @@
|
|||||||
- [x] WebSocket real-time sync (instead of polling)
|
- [x] WebSocket real-time sync (instead of polling)
|
||||||
- [ ] Automatic peer discovery via mDNS/Bonjour
|
- [ ] Automatic peer discovery via mDNS/Bonjour
|
||||||
- [x] Sync RSSI history for trilateration
|
- [x] Sync RSSI history for trilateration
|
||||||
|
- [x] Master dashboard: view peer node data without redirect
|
||||||
- [ ] Web UI for peer management
|
- [ ] Web UI for peer management
|
||||||
- [ ] Sync conflict resolution UI
|
- [ ] Sync conflict resolution UI
|
||||||
|
|
||||||
@@ -248,6 +249,7 @@
|
|||||||
- [x] Termux/Android environment detection with prerequisite checks
|
- [x] Termux/Android environment detection with prerequisite checks
|
||||||
- [x] Multi-scanner trilateration for device positioning
|
- [x] Multi-scanner trilateration for device positioning
|
||||||
- [x] Signal coverage heat map visualization
|
- [x] Signal coverage heat map visualization
|
||||||
|
- [x] Multi-node master dashboard (view peer data in single UI)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class ScannerConfig:
|
|||||||
latitude: float | None = None # Scanner position (falls back to gps.latitude)
|
latitude: float | None = None # Scanner position (falls back to gps.latitude)
|
||||||
longitude: float | None = None # Scanner position (falls back to gps.longitude)
|
longitude: float | None = None # Scanner position (falls back to gps.longitude)
|
||||||
floor: int | None = None # Scanner's floor (falls back to building.current_floor)
|
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
|
# Scanning configuration
|
||||||
wifi_interface: str = "wlan0"
|
wifi_interface: str = "wlan0"
|
||||||
@@ -179,6 +180,7 @@ class Config:
|
|||||||
latitude=data["scanner"].get("latitude", config.scanner.latitude),
|
latitude=data["scanner"].get("latitude", config.scanner.latitude),
|
||||||
longitude=data["scanner"].get("longitude", config.scanner.longitude),
|
longitude=data["scanner"].get("longitude", config.scanner.longitude),
|
||||||
floor=data["scanner"].get("floor", config.scanner.floor),
|
floor=data["scanner"].get("floor", config.scanner.floor),
|
||||||
|
is_master=data["scanner"].get("is_master", config.scanner.is_master),
|
||||||
# Scanning configuration
|
# Scanning configuration
|
||||||
wifi_interface=data["scanner"].get("wifi_interface", config.scanner.wifi_interface),
|
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),
|
bt_scan_timeout=data["scanner"].get("bt_scan_timeout", config.scanner.bt_scan_timeout),
|
||||||
@@ -330,6 +332,7 @@ class Config:
|
|||||||
"latitude": self.scanner.latitude,
|
"latitude": self.scanner.latitude,
|
||||||
"longitude": self.scanner.longitude,
|
"longitude": self.scanner.longitude,
|
||||||
"floor": self.scanner.floor,
|
"floor": self.scanner.floor,
|
||||||
|
"is_master": self.scanner.is_master,
|
||||||
# Scanning configuration
|
# Scanning configuration
|
||||||
"wifi_interface": self.scanner.wifi_interface,
|
"wifi_interface": self.scanner.wifi_interface,
|
||||||
"bt_scan_timeout": self.scanner.bt_scan_timeout,
|
"bt_scan_timeout": self.scanner.bt_scan_timeout,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
from flask import Flask, current_app, jsonify, render_template, request
|
from flask import Flask, current_app, jsonify, render_template, request
|
||||||
from flask_socketio import SocketIO, emit
|
from flask_socketio import SocketIO, emit
|
||||||
|
|
||||||
@@ -1453,11 +1454,13 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
if not db:
|
if not db:
|
||||||
return jsonify({"error": "Database not enabled"}), 503
|
return jsonify({"error": "Database not enabled"}), 503
|
||||||
|
|
||||||
|
rf_config = app.config["RF_CONFIG"]
|
||||||
peers = db.get_peers()
|
peers = db.get_peers()
|
||||||
peer_sync = app.config.get("PEER_SYNC")
|
peer_sync = app.config.get("PEER_SYNC")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"this_scanner": app.config["SCANNER_IDENTITY"],
|
"this_scanner": app.config["SCANNER_IDENTITY"],
|
||||||
|
"is_master": rf_config.scanner.is_master,
|
||||||
"peers": peers,
|
"peers": peers,
|
||||||
"sync_status": peer_sync.get_status() if peer_sync else None
|
"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
|
"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/<scanner_id>/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/<scanner_id>/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/<scanner_id>/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/<scanner_id>/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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,44 @@ body {
|
|||||||
align-items: center;
|
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 */
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ let trilaterationEnabled = true;
|
|||||||
// Heat map state
|
// Heat map state
|
||||||
let heatMapEnabled = false;
|
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
|
// Auto-scan state
|
||||||
let autoScanEnabled = false;
|
let autoScanEnabled = false;
|
||||||
let autoScanPollInterval = null;
|
let autoScanPollInterval = null;
|
||||||
@@ -132,6 +137,10 @@ let openPopupDeviceId = null; // Track which device popup is open
|
|||||||
|
|
||||||
// Initialize on DOM ready
|
// Initialize on DOM ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Store original position for node switching
|
||||||
|
APP_CONFIG.originalLat = APP_CONFIG.defaultLat;
|
||||||
|
APP_CONFIG.originalLon = APP_CONFIG.defaultLon;
|
||||||
|
|
||||||
initMap();
|
initMap();
|
||||||
initRadar();
|
initRadar();
|
||||||
initFloorSelector();
|
initFloorSelector();
|
||||||
@@ -149,6 +158,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Initialize WebSocket connection
|
// Initialize WebSocket connection
|
||||||
initWebSocket();
|
initWebSocket();
|
||||||
|
|
||||||
|
// Initialize master dashboard (checks if this node is master)
|
||||||
|
initMasterDashboard();
|
||||||
|
|
||||||
// Start BT live tracking by default after a short delay
|
// Start BT live tracking by default after a short delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
startLiveTracking();
|
startLiveTracking();
|
||||||
@@ -198,6 +210,176 @@ function initWebSocket() {
|
|||||||
rfMapperWS.on('scanUpdate', (data) => {
|
rfMapperWS.on('scanUpdate', (data) => {
|
||||||
handleWebSocketScanUpdate(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 = `<option value="local">📍 ${thisScanner.name || thisScanner.id}</option>`;
|
||||||
|
|
||||||
|
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
|
// Handle scan updates received via WebSocket
|
||||||
@@ -2186,7 +2368,12 @@ async function performLiveBTScan() {
|
|||||||
if (!liveTrackingEnabled) return;
|
if (!liveTrackingEnabled) return;
|
||||||
|
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,13 +4,17 @@
|
|||||||
class RFMapperWS {
|
class RFMapperWS {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
|
this.peerSocket = null; // For peer node connections (master dashboard)
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this.maxReconnectAttempts = 5;
|
this.maxReconnectAttempts = 5;
|
||||||
this.listeners = {
|
this.listeners = {
|
||||||
scanUpdate: [],
|
scanUpdate: [],
|
||||||
connected: [],
|
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) {
|
on(event, callback) {
|
||||||
if (this.listeners[event]) {
|
if (this.listeners[event]) {
|
||||||
this.listeners[event].push(callback);
|
this.listeners[event].push(callback);
|
||||||
|
|||||||
@@ -3,6 +3,12 @@
|
|||||||
{% block title %}RF Mapper - WiFi & Bluetooth Signal Map{% endblock %}
|
{% block title %}RF Mapper - WiFi & Bluetooth Signal Map{% endblock %}
|
||||||
|
|
||||||
{% block header_controls %}
|
{% block header_controls %}
|
||||||
|
<div id="node-selector-container" class="hidden">
|
||||||
|
<select id="node-select" onchange="switchNode(this.value)">
|
||||||
|
<option value="local" selected>📍 This Scanner</option>
|
||||||
|
</select>
|
||||||
|
<span id="node-status" class="node-status">●</span>
|
||||||
|
</div>
|
||||||
<span id="scan-status" class="scan-info">Ready</span>
|
<span id="scan-status" class="scan-info">Ready</span>
|
||||||
<button class="btn" id="scan-btn" onclick="triggerScan()">
|
<button class="btn" id="scan-btn" onclick="triggerScan()">
|
||||||
🔍 New Scan
|
🔍 New Scan
|
||||||
|
|||||||
Reference in New Issue
Block a user