feat: preserve source scanner for synced devices

When devices are synced from a peer, they now retain their original
source scanner reference. This ensures:

- Device positions are calculated relative to the scanner that
  detected them, not the local scanner
- Moving the local scanner won't affect synced devices' positions
- Popup shows "Source: <scanner_id>" for remotely-synced devices

Database changes:
- Added source_scanner_id, source_scanner_lat, source_scanner_lon
  columns to devices table
- get_devices_since() includes source scanner info in sync data
- bulk_update_devices() accepts and stores source scanner position
- Added get_all_device_sources() method

API changes:
- /api/sync/devices GET includes scanner_lat and scanner_lon
- /api/sync/devices POST accepts source_scanner_lat/lon
- /api/device/floors includes sources dict

Frontend changes:
- loadDevicePositions() loads source scanner info
- getDevicePosition() uses source scanner position for synced devices
- Popup shows source scanner info for remotely-synced devices

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
User
2026-02-01 04:23:42 +01:00
parent 2973178cb8
commit 8e25bf8871
5 changed files with 178 additions and 25 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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/<device_id>/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",

View File

@@ -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;

View File

@@ -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 ? `<button class="popup-reset-btn" onclick="resetDevicePosition('${wifiDeviceId}')">Reset to Auto</button>` : '';
const dragHint = isDraggable && !hasManualPosition ? '<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to set position</div>' : '';
const sourceInfo = pos.isRemoteSource ? `<div class="popup-source-info">📡 Source: ${pos.sourceScanner}</div>` : '';
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
<strong>📶 ${escapeHtml(net.ssid)}</strong><br>
@@ -1453,6 +1491,7 @@ function update3DMarkers() {
Distance: ${distLabel}<br>
Channel: ${net.channel}<br>
${escapeHtml(net.manufacturer)}<br>
${sourceInfo}
<div class="popup-floor-control">
<label>Floor:</label>
<select onchange="updateDeviceFloor('${wifiDeviceId}', this.value)">
@@ -1534,6 +1573,7 @@ function update3DMarkers() {
onclick="toggleDeviceTrail('${btDeviceId}', '${escapeHtml(dev.name)}', 'bluetooth')">
Show Trail
</button>` : '';
const btSourceInfo = pos.isRemoteSource ? `<div class="popup-source-info">📡 Source: ${pos.sourceScanner}</div>` : '';
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
<strong>${isMoving ? '🟣' : '🔵'} ${escapeHtml(dev.name)}</strong>${isMoving ? ' <span style="color:#9b59b6;font-size:0.8em;">(Moving)</span>' : ''}<br>
@@ -1541,6 +1581,7 @@ function update3DMarkers() {
Distance: ${btDistLabel}<br>
Type: ${escapeHtml(dev.device_type)}<br>
${escapeHtml(dev.manufacturer)}<br>
${btSourceInfo}
<div class="popup-floor-control">
<label>Floor:</label>
<select onchange="updateDeviceFloor('${btDeviceId}', this.value)">