diff --git a/src/rf_mapper/database.py b/src/rf_mapper/database.py index 5a36803..0b9bba2 100644 --- a/src/rf_mapper/database.py +++ b/src/rf_mapper/database.py @@ -496,6 +496,56 @@ class DeviceDatabase: max_distance_m=round(stats['max_distance_m'], 2) if stats['max_distance_m'] else 0 ) + def get_device_multi_scanner_rssi(self, device_id: str, seconds: int = 60) -> list[dict]: + """Get recent RSSI readings per scanner for a device. + + Args: + device_id: The device MAC address + seconds: Time window in seconds (default 60) + + Returns: + List of dicts with scanner_id, avg_rssi, sample_count, last_seen + """ + conn = self._get_connection() + cursor = conn.cursor() + + query = """ + SELECT scanner_id, + AVG(rssi) as avg_rssi, + COUNT(*) as sample_count, + MAX(timestamp) as last_seen + FROM rssi_history + WHERE device_id = ? + AND scanner_id IS NOT NULL + AND timestamp >= datetime('now', '-' || ? || ' seconds') + GROUP BY scanner_id + """ + cursor.execute(query, (device_id, seconds)) + return [dict(r) for r in cursor.fetchall()] + + def get_devices_seen_by_multiple_scanners(self, seconds: int = 60) -> list[str]: + """Get device IDs seen by 2+ scanners recently. + + Args: + seconds: Time window in seconds (default 60) + + Returns: + List of device IDs seen by multiple scanners + """ + conn = self._get_connection() + cursor = conn.cursor() + + query = """ + SELECT device_id + FROM rssi_history + WHERE scanner_id IS NOT NULL + AND timestamp >= datetime('now', '-' || ? || ' seconds') + GROUP BY device_id + HAVING COUNT(DISTINCT scanner_id) >= 2 + """ + cursor.execute(query, (seconds,)) + return [r['device_id'] for r in cursor.fetchall()] + def get_movement_events(self, device_id: Optional[str] = None, since: Optional[str] = None, limit: int = 100) -> list[dict]: diff --git a/src/rf_mapper/distance.py b/src/rf_mapper/distance.py index b65fba3..d5330cb 100644 --- a/src/rf_mapper/distance.py +++ b/src/rf_mapper/distance.py @@ -118,3 +118,85 @@ def trilaterate_2d( y = (A * F - D * C) / denom return (x, y) + + +def trilaterate_weighted_latlon( + scanner_data: list[dict] +) -> tuple[float, float, float] | None: + """ + Weighted trilateration using lat/lon coordinates. + + Args: + scanner_data: List of dicts with: + - lat: Scanner latitude + - lon: Scanner longitude + - distance: Estimated distance in meters + - rssi: Signal strength (for weighting) + + Returns: + (lat, lon, confidence) or None if insufficient data + """ + if len(scanner_data) < 2: + return None + + # Calculate weights from RSSI (higher = better) + for s in scanner_data: + s['weight'] = 10 ** ((s['rssi'] + 100) / 40) # Normalize -100 to 0 range + + total_weight = sum(s['weight'] for s in scanner_data) + + if len(scanner_data) == 2: + # Two scanners: weighted average along line between them + s1, s2 = scanner_data[0], scanner_data[1] + + # Calculate position along line based on distance ratio + total_dist = s1['distance'] + s2['distance'] + if total_dist == 0: + ratio = 0.5 + else: + ratio = s1['distance'] / total_dist + + # Weighted interpolation + lat = s1['lat'] + ratio * (s2['lat'] - s1['lat']) + lon = s1['lon'] + ratio * (s2['lon'] - s1['lon']) + + # Lower confidence for 2-scanner solution + confidence = 0.5 + return (lat, lon, confidence) + + # 3+ scanners: convert to local XY and trilaterate + # Use centroid as origin + center_lat = sum(s['lat'] for s in scanner_data) / len(scanner_data) + center_lon = sum(s['lon'] for s in scanner_data) / len(scanner_data) + + # Convert to meters from centroid + positions = [] + distances = [] + for s in scanner_data: + x = (s['lon'] - center_lon) * 111000 * math.cos(math.radians(center_lat)) + y = (s['lat'] - center_lat) * 111000 + positions.append((x, y)) + distances.append(s['distance']) + + # Use existing trilaterate_2d + result = trilaterate_2d(positions, distances) + if result is None: + return None + + x, y = result + + # Convert back to lat/lon + lat = center_lat + (y / 111000) + lon = center_lon + (x / (111000 * math.cos(math.radians(center_lat)))) + + # Calculate confidence based on residuals + residuals = [] + for i, s in enumerate(scanner_data): + calc_dist = math.sqrt((x - positions[i][0])**2 + (y - positions[i][1])**2) + residuals.append(abs(calc_dist - distances[i])) + + avg_residual = sum(residuals) / len(residuals) + avg_distance = sum(distances) / len(distances) + confidence = max(0, min(1, 1 - (avg_residual / max(avg_distance, 1)))) + + return (lat, lon, confidence) diff --git a/src/rf_mapper/web/app.py b/src/rf_mapper/web/app.py index 3475fec..6d79a8e 100644 --- a/src/rf_mapper/web/app.py +++ b/src/rf_mapper/web/app.py @@ -1294,6 +1294,132 @@ def create_app(config: Config | None = None) -> Flask: return jsonify(activity) + # ==================== Trilateration API ==================== + + @app.route("/api/positions/trilaterated") + def api_trilaterated_positions(): + """Get trilaterated positions for devices seen by multiple scanners.""" + from ..distance import trilaterate_weighted_latlon, estimate_distance + + db = app.config.get("DATABASE") + if not db: + return jsonify({"error": "Database not enabled"}), 503 + + seconds = int(request.args.get("seconds", 60)) + + # Get devices seen by multiple scanners + device_ids = db.get_devices_seen_by_multiple_scanners(seconds=seconds) + + # Get all scanner positions (local + peers) + scanner_positions = {} + + # Local scanner + local = app.config.get("SCANNER_IDENTITY", {}) + if local.get("id"): + scanner_positions[local["id"]] = { + "lat": local.get("latitude"), + "lon": local.get("longitude") + } + + # Peer scanners + peers = db.get_peers() + for peer in peers: + scanner_positions[peer["scanner_id"]] = { + "lat": peer.get("latitude"), + "lon": peer.get("longitude") + } + + results = [] + for device_id in device_ids: + scanner_rssi = db.get_device_multi_scanner_rssi(device_id, seconds=seconds) + + # Build scanner data for trilateration + scanner_data = [] + for sr in scanner_rssi: + sid = sr["scanner_id"] + if sid in scanner_positions and scanner_positions[sid].get("lat"): + pos = scanner_positions[sid] + dist = estimate_distance(int(sr["avg_rssi"]), tx_power=-65) + scanner_data.append({ + "scanner_id": sid, + "lat": pos["lat"], + "lon": pos["lon"], + "distance": dist, + "rssi": sr["avg_rssi"] + }) + + if len(scanner_data) < 2: + continue + + # Calculate trilaterated position + result = trilaterate_weighted_latlon(scanner_data) + if result: + lat, lon, confidence = result + results.append({ + "device_id": device_id, + "position": {"lat": lat, "lon": lon}, + "confidence": round(confidence, 2), + "scanners": [s["scanner_id"] for s in scanner_data], + "method": "trilateration" if len(scanner_data) >= 3 else "weighted_average" + }) + + return jsonify({"devices": results, "count": len(results)}) + + @app.route("/api/heatmap/signal") + def api_signal_heatmap(): + """Get signal strength data for heat map visualization.""" + db = app.config.get("DATABASE") + if not db: + return jsonify({"error": "Database not enabled"}), 503 + + floor = request.args.get("floor") + + # Get scanner positions as anchor points (max signal) + points = [] + + # Local scanner + local = app.config.get("SCANNER_IDENTITY", {}) + if local.get("latitude"): + points.append({ + "lat": local["latitude"], + "lon": local["longitude"], + "signal": -30, # Scanner location = max signal + "weight": 1.0 + }) + + # Peer scanners + peers = db.get_peers() + for peer in peers: + if peer.get("latitude"): + points.append({ + "lat": peer["latitude"], + "lon": peer["longitude"], + "signal": -30, + "weight": 1.0 + }) + + # Get recent device observations with positions + # Use trilaterated positions if available, otherwise source scanner positions + devices = db.get_all_devices(device_type="bluetooth", limit=100) + for dev in devices: + # Get most recent RSSI for this device + rssi_history = db.get_device_rssi_history(dev["device_id"], limit=1) + if rssi_history: + last_rssi = rssi_history[0].rssi + # Get device source info + if dev.get("source_scanner_lat") and dev.get("source_scanner_lon"): + # Calculate rough position from source scanner + from ..distance import estimate_distance + dist = estimate_distance(last_rssi, tx_power=-65) + points.append({ + "lat": dev["source_scanner_lat"], + "lon": dev["source_scanner_lon"], + "signal": last_rssi, + "weight": max(0.1, min(0.8, 1 - (dist / 50))) # Weight by distance + }) + + return jsonify({"points": points, "count": len(points)}) + @app.route("/api/history/stats") def api_history_stats(): """Get database statistics""" diff --git a/src/rf_mapper/web/static/css/style.css b/src/rf_mapper/web/static/css/style.css index 5acbdff..bc75d48 100644 --- a/src/rf_mapper/web/static/css/style.css +++ b/src/rf_mapper/web/static/css/style.css @@ -832,6 +832,10 @@ body { color: var(--color-primary); } +.popup-position-status .status-value.trilaterated { + color: #ffd700; +} + .popup-source-info { margin-top: 6px; padding: 4px 8px; @@ -974,6 +978,39 @@ body { background: rgba(0, 200, 255, 0.9); } +/* Trilaterated device markers - gold border */ +.marker-3d.trilaterated .marker-icon { + border: 2px dashed #ffd700 !important; +} + +.marker-3d.trilaterated.high-confidence .marker-icon { + border-style: solid !important; + box-shadow: 0 0 10px rgba(255, 215, 0, 0.7), 0 2px 8px rgba(0, 0, 0, 0.5) !important; +} + +.trilat-badge { + position: absolute; + top: -6px; + right: -6px; + background: #ffd700; + color: #000; + font-size: 7px; + padding: 1px 3px; + border-radius: 3px; + font-weight: bold; +} + +/* Heat map toggle button */ +.filter-btn.heatmap { + color: #ff6b6b; + border-color: #ff6b6b; +} + +.filter-btn.heatmap.active { + background: rgba(255, 107, 107, 0.2); + color: #ff6b6b; +} + /* Floor Controls */ .floor-section { display: block; diff --git a/src/rf_mapper/web/static/js/app.js b/src/rf_mapper/web/static/js/app.js index 53fe68a..cc09713 100644 --- a/src/rf_mapper/web/static/js/app.js +++ b/src/rf_mapper/web/static/js/app.js @@ -24,6 +24,13 @@ let deviceSources = {}; // { deviceId: { scanner_id, lat, lon } } // Peer scanner positions - loaded from /api/peers (live positions) let peerScanners = {}; // { scanner_id: { lat, lon, floor, name } } +// Trilateration state - positions calculated from multiple scanner RSSI +let trilateratedPositions = {}; // { deviceId: { lat, lon, confidence, scanners, method } } +let trilaterationEnabled = true; + +// Heat map state +let heatMapEnabled = false; + // Auto-scan state let autoScanEnabled = false; let autoScanPollInterval = null; @@ -131,6 +138,7 @@ document.addEventListener('DOMContentLoaded', () => { loadLatestScan(); loadAutoScanStatus(); loadDevicePositions(); // Load saved manual positions + loadTrilateratedPositions(); // Load multi-scanner trilaterated positions // Initialize 3D map as default view setTimeout(() => { @@ -287,6 +295,9 @@ function handleWebSocketScanUpdate(data) { document.getElementById('bt-count').textContent = scanData.bluetooth_devices.length; document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices.length; + // Refresh trilaterated positions (non-blocking) + loadTrilateratedPositions(); + // Refresh views drawRadar(); update3DMarkers(); @@ -1256,13 +1267,53 @@ async function loadDevicePositions() { } } -// Get device position (manual or RSSI-based) +// Load trilaterated positions from multi-scanner RSSI data +async function loadTrilateratedPositions() { + if (!trilaterationEnabled) return; + + try { + const resp = await fetch('/api/positions/trilaterated'); + if (!resp.ok) return; + + const data = await resp.json(); + trilateratedPositions = {}; + (data.devices || []).forEach(d => { + trilateratedPositions[d.device_id] = { + lat: d.position.lat, + lon: d.position.lon, + confidence: d.confidence, + scanners: d.scanners, + method: d.method + }; + }); + console.log(`[Trilateration] Loaded ${data.count} positions`); + } catch (e) { + console.error('[Trilateration] Error:', e); + } +} + +// Get device position (manual, trilaterated, or RSSI-based) +// Priority: 1. Manual position, 2. Trilaterated (if confidence > 0.3), 3. RSSI-based // Uses source scanner position for synced devices so they don't move when local scanner moves function getDevicePosition(device, scannerLat, scannerLon, minDistanceM) { const deviceId = device.bssid || device.address; const customPos = manualPositions[deviceId]; const sourceInfo = deviceSources[deviceId]; + // Check for trilaterated position first (if enabled and confident) + const triData = trilateratedPositions[deviceId]; + if (trilaterationEnabled && triData && triData.confidence > 0.3) { + return { + lat: triData.lat, + lon: triData.lon, + isManual: false, + isTrilaterated: true, + confidence: triData.confidence, + scannerCount: triData.scanners.length, + method: triData.method + }; + } + // Determine which scanner position to use for this device // If device was synced from another scanner, use that scanner's CURRENT position let baseLat = scannerLat; @@ -1780,26 +1831,33 @@ function update3DMarkers() { const missCount = dev.miss_count || 0; // Calculate opacity: 1.0 -> 0.6 -> 0.3 based on miss count const opacity = missCount === 0 ? 1.0 : (missCount === 1 ? 0.6 : 0.3); + const isTrilaterated = pos.isTrilaterated === true; const el = document.createElement('div'); el.className = `marker-3d bluetooth${isMoving ? ' moving' : ''}`; if (isDraggable) el.classList.add('draggable'); if (hasManualPosition) el.classList.add('has-manual-position'); + if (isTrilaterated) { + el.classList.add('trilaterated'); + if (pos.confidence >= 0.7) el.classList.add('high-confidence'); + } el.style.opacity = opacity; el.style.transition = 'opacity 0.5s ease'; - el.innerHTML = `
${isMoving ? '🟣' : '🔵'}
${btFloorLabel}
`; - el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}${hasManualPosition ? ' (Manual position)' : ''}${missCount > 0 ? ` (fading: ${missCount}/${MAX_MISSED_SCANS})` : ''}`; + const trilatBadge = isTrilaterated ? `${pos.scannerCount}📡` : ''; + el.innerHTML = `
${isMoving ? '🟣' : '🔵'}${trilatBadge}
${btFloorLabel}
`; + el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}${isTrilaterated ? ` (Trilaterated: ${pos.scannerCount} scanners, ${Math.round(pos.confidence*100)}% conf)` : ''}${hasManualPosition ? ' (Manual position)' : ''}${missCount > 0 ? ` (fading: ${missCount}/${MAX_MISSED_SCANS})` : ''}`; const btDistLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${dev.estimated_distance_m}m`; - const positionStatus = hasManualPosition ? 'Manual' : 'Auto'; - const positionClass = hasManualPosition ? 'manual' : 'auto'; + const positionStatus = isTrilaterated ? `Trilaterated (${pos.scannerCount} scanners)` : (hasManualPosition ? 'Manual' : 'Auto'); + const positionClass = isTrilaterated ? 'trilaterated' : (hasManualPosition ? 'manual' : 'auto'); const resetBtn = hasManualPosition ? `` : ''; - const dragHint = isDraggable && !hasManualPosition ? '
Drag marker to set position
' : ''; + const dragHint = isDraggable && !hasManualPosition && !isTrilaterated ? '
Drag marker to set position
' : ''; const trailBtnHtml = isMoving ? ` ` : ''; const btSourceInfo = pos.isRemoteSource ? `` : ''; + const trilatInfo = isTrilaterated ? `` : ''; const popup = new maplibregl.Popup({ offset: 25 }).setHTML(` ${isMoving ? '🟣' : '🔵'} ${escapeHtml(dev.name)}${isMoving ? ' (Moving)' : ''}
@@ -1808,6 +1866,7 @@ function update3DMarkers() { Type: ${escapeHtml(dev.device_type)}
${escapeHtml(dev.manufacturer)}
${btSourceInfo} + ${trilatInfo}