Files
rf-mapper/src/rf_mapper/sync.py
User 8e25bf8871 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>
2026-02-01 04:23:42 +01:00

219 lines
6.9 KiB
Python

"""Peer synchronization for RF Mapper multi-scanner deployments"""
import socket
import threading
import time
from datetime import datetime
from typing import Optional
import requests
from .config import Config
from .database import DeviceDatabase
def get_local_ip() -> str:
"""Get the local IP address of this machine."""
try:
# Create a socket and connect to an external address
# This doesn't actually send data, just determines the local IP
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception:
return "127.0.0.1"
class PeerSync:
"""Manages peer discovery and device metadata synchronization.
Handles:
- Registration with peer scanners
- Background sync thread for pulling/pushing device metadata
- Conflict resolution using timestamps
"""
def __init__(self, config: Config, db: DeviceDatabase):
"""Initialize peer sync manager.
Args:
config: RF Mapper configuration
db: Device database instance
"""
self.config = config
self.db = db
self.scanner_identity = config.get_scanner_identity()
self._running = False
self._thread: Optional[threading.Thread] = None
self._last_sync: dict[str, str] = {} # peer_id -> last sync timestamp
@property
def local_url(self) -> str:
"""Get this scanner's URL for peer registration."""
return f"http://{get_local_ip()}:{self.config.web.port}"
def start(self):
"""Start the background sync thread."""
if self._thread and self._thread.is_alive():
return # Already running
self._running = True
self._thread = threading.Thread(target=self._sync_loop, daemon=True)
self._thread.start()
print(f"[Sync] Background sync started (interval: {self.config.scanner.sync_interval_seconds}s)")
def stop(self):
"""Stop the background sync thread."""
self._running = False
if self._thread:
self._thread.join(timeout=5)
self._thread = None
print("[Sync] Background sync stopped")
def register_with_peer(self, peer_url: str) -> dict:
"""Register this scanner with a peer.
Args:
peer_url: Base URL of the peer scanner
Returns:
Response from peer containing peer info and known peers
Raises:
requests.RequestException on network errors
"""
payload = {
"id": self.scanner_identity["id"],
"name": self.scanner_identity["name"],
"url": self.local_url,
"floor": self.scanner_identity["floor"],
"latitude": self.scanner_identity["latitude"],
"longitude": self.scanner_identity["longitude"]
}
resp = requests.post(
f"{peer_url.rstrip('/')}/api/peers/register",
json=payload,
timeout=10
)
resp.raise_for_status()
return resp.json()
def sync_devices_from_peer(self, peer_url: str, since: Optional[str] = None) -> int:
"""Pull device updates from a peer.
Args:
peer_url: Base URL of the peer scanner
since: ISO timestamp to get updates since (None = all)
Returns:
Number of devices updated locally
"""
params = {"since": since} if since else {}
resp = requests.get(
f"{peer_url.rstrip('/')}/api/sync/devices",
params=params,
timeout=15
)
resp.raise_for_status()
data = resp.json()
devices = data.get("devices", [])
source_scanner = data.get("scanner_id", "unknown")
# 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:
"""Push device updates to a peer.
Args:
peer_url: Base URL of the peer scanner
since: ISO timestamp to send updates since (None = all)
Returns:
Response from peer with sync status
"""
devices = self.db.get_devices_since(since)
payload = {
"source_scanner": self.scanner_identity["id"],
"source_scanner_lat": self.scanner_identity["latitude"],
"source_scanner_lon": self.scanner_identity["longitude"],
"devices": devices
}
resp = requests.post(
f"{peer_url.rstrip('/')}/api/sync/devices",
json=payload,
timeout=15
)
resp.raise_for_status()
return resp.json()
def _sync_loop(self):
"""Background sync loop - runs every sync_interval_seconds."""
# Initial delay to let app fully start
time.sleep(5)
while self._running:
peers = self.db.get_peers()
for peer in peers:
peer_id = peer["scanner_id"]
peer_url = peer["url"]
try:
# Pull updates from peer
since = self._last_sync.get(peer_id)
updated = self.sync_devices_from_peer(peer_url, since)
# Push our updates to peer
self.push_devices_to_peer(peer_url, since)
# Update last sync time and peer last_seen
self._last_sync[peer_id] = datetime.now().isoformat()
self.db.update_peer_last_seen(peer_id)
if updated > 0:
print(f"[Sync] Synced with {peer_id}: updated {updated} devices")
except requests.exceptions.ConnectionError:
print(f"[Sync] Peer {peer_id} unreachable at {peer_url}")
except requests.exceptions.Timeout:
print(f"[Sync] Sync with {peer_id} timed out")
except Exception as e:
print(f"[Sync] Error syncing with {peer_id}: {e}")
# Wait for next sync interval
for _ in range(self.config.scanner.sync_interval_seconds):
if not self._running:
break
time.sleep(1)
def get_status(self) -> dict:
"""Get current sync status.
Returns:
Dict with running state, peer count, last sync times
"""
peers = self.db.get_peers()
return {
"running": self._running,
"sync_interval_seconds": self.config.scanner.sync_interval_seconds,
"peer_count": len(peers),
"last_sync": self._last_sync.copy(),
"local_url": self.local_url
}