diff --git a/src/rf_mapper/database.py b/src/rf_mapper/database.py index 657d66b..5a36803 100644 --- a/src/rf_mapper/database.py +++ b/src/rf_mapper/database.py @@ -216,6 +216,22 @@ class DeviceDatabase: except sqlite3.OperationalError: pass # Column already exists + # Add source_scanner columns for peer sync (device positions relative to source scanner) + try: + cursor.execute("ALTER TABLE devices ADD COLUMN source_scanner_id TEXT") + except sqlite3.OperationalError: + pass # Column already exists + + try: + cursor.execute("ALTER TABLE devices ADD COLUMN source_scanner_lat REAL") + except sqlite3.OperationalError: + pass # Column already exists + + try: + cursor.execute("ALTER TABLE devices ADD COLUMN source_scanner_lon REAL") + except sqlite3.OperationalError: + pass # Column already exists + conn.commit() def record_scan(self, scan_id: str, timestamp: str, location_label: str, @@ -586,6 +602,42 @@ class DeviceDatabase: cursor.execute("SELECT device_id, assigned_floor FROM devices WHERE assigned_floor IS NOT NULL") return {row['device_id']: row['assigned_floor'] for row in cursor.fetchall()} + def get_all_device_sources(self) -> dict: + """Get all device source scanner info as a dict. + + Returns: + Dict mapping device_id to {scanner_id, lat, lon} or None if local + """ + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + SELECT device_id, source_scanner_id, source_scanner_lat, source_scanner_lon + FROM devices + WHERE source_scanner_id IS NOT NULL + """) + return { + row['device_id']: { + 'scanner_id': row['source_scanner_id'], + 'lat': row['source_scanner_lat'], + 'lon': row['source_scanner_lon'] + } + for row in cursor.fetchall() + } + + def set_device_source(self, device_id: str, scanner_id: str, + scanner_lat: float, scanner_lon: float): + """Set source scanner info for a device (where it was detected/positioned).""" + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute(""" + UPDATE devices + SET source_scanner_id = ?, source_scanner_lat = ?, source_scanner_lon = ? + WHERE device_id = ? + """, (scanner_id, scanner_lat, scanner_lon, device_id)) + conn.commit() + def set_device_position(self, device_id: str, lat_offset: float, lon_offset: float): """Set custom position offset for a device (relative to scanner position)""" conn = self._get_connection() @@ -931,14 +983,17 @@ class DeviceDatabase: Returns: List of device dicts with sync-relevant fields. Note: Position offsets are NOT synced as they are relative to each scanner's location. + Source scanner info IS synced so receiving scanners can calculate positions correctly. """ conn = self._get_connection() cursor = conn.cursor() + # Include source scanner info so receiving scanners know where device was detected # Don't sync position offsets - they're relative to each scanner's location query = """ SELECT device_id, device_type, name, ssid, manufacturer, - custom_label, assigned_floor, is_favorite, notes, updated_at + custom_label, assigned_floor, is_favorite, notes, updated_at, + source_scanner_id, source_scanner_lat, source_scanner_lon FROM devices WHERE (custom_label IS NOT NULL OR assigned_floor IS NOT NULL OR is_favorite = 1 OR notes IS NOT NULL) @@ -954,15 +1009,21 @@ class DeviceDatabase: cursor.execute(query, params) return [dict(row) for row in cursor.fetchall()] - def bulk_update_devices(self, devices: list[dict], source_scanner: str) -> int: + def bulk_update_devices(self, devices: list[dict], source_scanner: str, + source_scanner_lat: Optional[float] = None, + source_scanner_lon: Optional[float] = None) -> int: """Bulk update device metadata from peer sync. Uses timestamp-based conflict resolution: newer wins. Only updates non-null fields from peer. + Preserves source scanner info so device positions are calculated relative to + the scanner that originally detected them. Args: devices: List of device dicts from peer source_scanner: Scanner ID that sent the update + source_scanner_lat: Latitude of source scanner (for position calculation) + source_scanner_lon: Longitude of source scanner (for position calculation) Returns: Number of devices updated @@ -978,11 +1039,17 @@ class DeviceDatabase: # Get existing device cursor.execute( - "SELECT updated_at FROM devices WHERE device_id = ?", + "SELECT updated_at, source_scanner_id FROM devices WHERE device_id = ?", (device_id,) ) existing = cursor.fetchone() + # Determine source scanner info for this device + # Use device's original source if present, otherwise use the peer sending the data + dev_source_id = dev.get("source_scanner_id") or source_scanner + dev_source_lat = dev.get("source_scanner_lat") or source_scanner_lat + dev_source_lon = dev.get("source_scanner_lon") or source_scanner_lon + if existing: # Check timestamp - skip if local is newer local_updated = existing["updated_at"] or "" @@ -1012,6 +1079,18 @@ class DeviceDatabase: updates.append("notes = ?") params.append(dev["notes"]) + # Update source scanner info if not already set locally + # (preserve original source, don't overwrite with intermediate peer) + if not existing["source_scanner_id"] and dev_source_id: + updates.append("source_scanner_id = ?") + params.append(dev_source_id) + if dev_source_lat is not None: + updates.append("source_scanner_lat = ?") + params.append(dev_source_lat) + if dev_source_lon is not None: + updates.append("source_scanner_lon = ?") + params.append(dev_source_lon) + if updates: # Keep the peer's updated_at to preserve timeline updates.append("updated_at = ?") @@ -1034,13 +1113,15 @@ class DeviceDatabase: cursor.execute(""" INSERT INTO devices (device_id, device_type, name, ssid, manufacturer, custom_label, assigned_floor, is_favorite, notes, - first_seen, last_seen, total_observations, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?) + first_seen, last_seen, total_observations, updated_at, + source_scanner_id, source_scanner_lat, source_scanner_lon) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?) """, ( device_id, device_type, name, dev.get("ssid"), dev.get("manufacturer"), dev.get("custom_label"), dev.get("assigned_floor"), 1 if dev.get("is_favorite") else 0, dev.get("notes"), - now, now, dev.get("updated_at", now) + now, now, dev.get("updated_at", now), + dev_source_id, dev_source_lat, dev_source_lon )) updated_count += 1 diff --git a/src/rf_mapper/sync.py b/src/rf_mapper/sync.py index 2c65b1e..ef23616 100644 --- a/src/rf_mapper/sync.py +++ b/src/rf_mapper/sync.py @@ -124,7 +124,15 @@ class PeerSync: devices = data.get("devices", []) source_scanner = data.get("scanner_id", "unknown") - updated = self.db.bulk_update_devices(devices, source_scanner) + # Get source scanner position for correct device positioning + source_lat = data.get("scanner_lat") + source_lon = data.get("scanner_lon") + + updated = self.db.bulk_update_devices( + devices, source_scanner, + source_scanner_lat=source_lat, + source_scanner_lon=source_lon + ) return updated def push_devices_to_peer(self, peer_url: str, since: Optional[str] = None) -> dict: @@ -141,6 +149,8 @@ class PeerSync: payload = { "source_scanner": self.scanner_identity["id"], + "source_scanner_lat": self.scanner_identity["latitude"], + "source_scanner_lon": self.scanner_identity["longitude"], "devices": devices } diff --git a/src/rf_mapper/web/app.py b/src/rf_mapper/web/app.py index a474867..77b6533 100644 --- a/src/rf_mapper/web/app.py +++ b/src/rf_mapper/web/app.py @@ -880,17 +880,19 @@ def create_app(config: Config | None = None) -> Flask: @app.route("/api/device/floors", methods=["GET"]) def api_device_floors(): - """Get all saved floor assignments and position offsets""" + """Get all saved floor assignments, position offsets, and source scanner info""" db = app.config.get("DATABASE") if not db: - return jsonify({"floors": {}, "positions": {}}) + return jsonify({"floors": {}, "positions": {}, "sources": {}}) floors = db.get_all_device_floors() positions = db.get_all_device_positions() + sources = db.get_all_device_sources() return jsonify({ "floors": floors, - "positions": positions + "positions": positions, + "sources": sources }) @app.route("/api/device//position", methods=["POST"]) @@ -1348,9 +1350,12 @@ def create_app(config: Config | None = None) -> Flask: since = request.args.get("since") devices = db.get_devices_since(since) + scanner_identity = app.config["SCANNER_IDENTITY"] return jsonify({ - "scanner_id": app.config["SCANNER_IDENTITY"]["id"], + "scanner_id": scanner_identity["id"], + "scanner_lat": scanner_identity["latitude"], + "scanner_lon": scanner_identity["longitude"], "timestamp": datetime.now().isoformat(), "devices": devices }) @@ -1365,8 +1370,14 @@ def create_app(config: Config | None = None) -> Flask: data = request.get_json() or {} devices = data.get("devices", []) source_scanner = data.get("source_scanner", "unknown") + source_lat = data.get("source_scanner_lat") + source_lon = data.get("source_scanner_lon") - updated = db.bulk_update_devices(devices, source_scanner) + updated = db.bulk_update_devices( + devices, source_scanner, + source_scanner_lat=source_lat, + source_scanner_lon=source_lon + ) return jsonify({ "status": "synced", diff --git a/src/rf_mapper/web/static/css/style.css b/src/rf_mapper/web/static/css/style.css index e1b1d52..2f80252 100644 --- a/src/rf_mapper/web/static/css/style.css +++ b/src/rf_mapper/web/static/css/style.css @@ -832,6 +832,16 @@ body { color: var(--color-primary); } +.popup-source-info { + margin-top: 6px; + padding: 4px 8px; + background: rgba(0, 200, 255, 0.1); + border: 1px solid rgba(0, 200, 255, 0.3); + border-radius: var(--border-radius); + font-size: 0.8rem; + color: #00c8ff; +} + .popup-reset-btn { margin-top: 4px; padding: 4px 10px; diff --git a/src/rf_mapper/web/static/js/app.js b/src/rf_mapper/web/static/js/app.js index 4f66e0b..0a17b2d 100644 --- a/src/rf_mapper/web/static/js/app.js +++ b/src/rf_mapper/web/static/js/app.js @@ -18,6 +18,9 @@ let deviceTrails = {}; // { deviceId: { type, name, points: [{ timestamp, distan // Device manual positions - loaded from database let manualPositions = {}; // { deviceId: { lat_offset, lon_offset } } +// Device source scanner info - loaded from database (for synced devices) +let deviceSources = {}; // { deviceId: { scanner_id, lat, lon } } + // Auto-scan state let autoScanEnabled = false; let autoScanPollInterval = null; @@ -1059,17 +1062,21 @@ async function stopAutoScan() { // ========== Device Position Functions ========== -// Load saved device positions from database +// Load saved device positions and source scanner info from database async function loadDevicePositions() { try { const response = await fetch('/api/device/floors'); if (response.ok) { const data = await response.json(); - // Handle both old format (just floors) and new format (floors + positions) + // Handle both old format (just floors) and new format (floors + positions + sources) if (data.positions) { manualPositions = data.positions; console.log('[Positions] Loaded', Object.keys(manualPositions).length, 'manual positions'); } + if (data.sources) { + deviceSources = data.sources; + console.log('[Sources] Loaded', Object.keys(deviceSources).length, 'device sources'); + } } } catch (error) { console.error('Error loading device positions:', error); @@ -1077,31 +1084,49 @@ async function loadDevicePositions() { } // Get device position (manual or 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]; - // If device has manual position, use it + // Determine which scanner position to use for this device + // If device was synced from another scanner, use that scanner's position + let baseLat = scannerLat; + let baseLon = scannerLon; + let isFromRemoteScanner = false; + + if (sourceInfo && sourceInfo.lat != null && sourceInfo.lon != null) { + baseLat = sourceInfo.lat; + baseLon = sourceInfo.lon; + isFromRemoteScanner = true; + } + + // If device has manual position, use it (relative to source scanner) if (customPos && customPos.lat_offset != null && customPos.lon_offset != null) { return { - lat: scannerLat + customPos.lat_offset, - lon: scannerLon + customPos.lon_offset, - isManual: true + lat: baseLat + customPos.lat_offset, + lon: baseLon + customPos.lon_offset, + isManual: true, + isRemoteSource: isFromRemoteScanner, + sourceScanner: sourceInfo?.scanner_id || null }; } - // Otherwise calculate from RSSI/distance + // Otherwise calculate from RSSI/distance (relative to source scanner) const effectiveDist = getEffectiveDistance(device); const dist = Math.max(effectiveDist, minDistanceM); const angle = hashString(deviceId) % 360; const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000; - const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(scannerLat * Math.PI / 180)); + const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(baseLat * Math.PI / 180)); return { - lat: scannerLat + latOffset, - lon: scannerLon + lonOffset, - isManual: false + lat: baseLat + latOffset, + lon: baseLon + lonOffset, + isManual: false, + isRemoteSource: isFromRemoteScanner, + sourceScanner: sourceInfo?.scanner_id || null }; } @@ -1198,10 +1223,22 @@ async function onScannerDragEnd(marker) { } // Handle marker drag end +// Uses source scanner position for synced devices async function onMarkerDragEnd(marker, deviceId, scannerLat, scannerLon) { const lngLat = marker.getLngLat(); - const latOffset = lngLat.lat - scannerLat; - const lonOffset = lngLat.lng - scannerLon; + + // Determine base position (source scanner for synced devices, local scanner otherwise) + const sourceInfo = deviceSources[deviceId]; + let baseLat = scannerLat; + let baseLon = scannerLon; + + if (sourceInfo && sourceInfo.lat != null && sourceInfo.lon != null) { + baseLat = sourceInfo.lat; + baseLon = sourceInfo.lon; + } + + const latOffset = lngLat.lat - baseLat; + const lonOffset = lngLat.lng - baseLon; const success = await updateDevicePosition(deviceId, latOffset, lonOffset); @@ -1446,6 +1483,7 @@ function update3DMarkers() { const positionClass = hasManualPosition ? 'manual' : 'auto'; const resetBtn = hasManualPosition ? `` : ''; const dragHint = isDraggable && !hasManualPosition ? '
Drag marker to set position
' : ''; + const sourceInfo = pos.isRemoteSource ? `` : ''; const popup = new maplibregl.Popup({ offset: 25 }).setHTML(` 📶 ${escapeHtml(net.ssid)}
@@ -1453,6 +1491,7 @@ function update3DMarkers() { Distance: ${distLabel}
Channel: ${net.channel}
${escapeHtml(net.manufacturer)}
+ ${sourceInfo}