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