feat: add peer sync for multi-scanner deployments
Enable scanner instances to discover each other and synchronize device metadata (floors, positions, labels, favorites) automatically. New features: - Peer registration API with mutual auto-registration - Background sync thread with configurable interval - Timestamp-based conflict resolution (newest wins) - Config options: peers, sync_interval_seconds, accept_registrations API endpoints: - GET/POST /api/peers - list peers, register new peer - DELETE /api/peers/<id> - remove peer - GET/POST /api/sync/devices - device sync for peers - POST /api/sync/trigger - manual sync trigger Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -167,26 +167,102 @@ class DeviceDatabase:
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
|
||||
# Add custom position columns for manual position override (migration)
|
||||
try:
|
||||
cursor.execute("ALTER TABLE devices ADD COLUMN custom_lat_offset REAL")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
|
||||
try:
|
||||
cursor.execute("ALTER TABLE devices ADD COLUMN custom_lon_offset REAL")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
|
||||
# Add departure_notified column for HA integration (migration)
|
||||
try:
|
||||
cursor.execute("ALTER TABLE devices ADD COLUMN departure_notified INTEGER DEFAULT 0")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
|
||||
# Add scanner_id column to scans table for multi-scanner support (migration)
|
||||
try:
|
||||
cursor.execute("ALTER TABLE scans ADD COLUMN scanner_id TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
|
||||
# Add scanner_id column to rssi_history for multi-scanner support (migration)
|
||||
try:
|
||||
cursor.execute("ALTER TABLE rssi_history ADD COLUMN scanner_id TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
|
||||
# Peers table - known scanner peers for sync
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS peers (
|
||||
scanner_id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
url TEXT NOT NULL,
|
||||
floor INTEGER,
|
||||
latitude REAL,
|
||||
longitude REAL,
|
||||
last_seen TEXT,
|
||||
registered_at TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
# Add notes column to devices table if missing (for sync)
|
||||
try:
|
||||
cursor.execute("ALTER TABLE devices ADD COLUMN notes TEXT")
|
||||
except sqlite3.OperationalError:
|
||||
pass # Column already exists
|
||||
|
||||
conn.commit()
|
||||
|
||||
def record_scan(self, scan_id: str, timestamp: str, location_label: str,
|
||||
lat: float, lon: float, wifi_count: int, bt_count: int):
|
||||
"""Record a scan event"""
|
||||
lat: float, lon: float, wifi_count: int, bt_count: int,
|
||||
scanner_id: Optional[str] = None):
|
||||
"""Record a scan event
|
||||
|
||||
Args:
|
||||
scan_id: Unique identifier for this scan
|
||||
timestamp: ISO timestamp of the scan
|
||||
location_label: User-defined location label
|
||||
lat: Latitude of scan location
|
||||
lon: Longitude of scan location
|
||||
wifi_count: Number of WiFi networks detected
|
||||
bt_count: Number of Bluetooth devices detected
|
||||
scanner_id: ID of the scanner that performed this scan
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO scans (scan_id, timestamp, location_label, lat, lon, wifi_count, bt_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (scan_id, timestamp, location_label, lat, lon, wifi_count, bt_count))
|
||||
INSERT OR REPLACE INTO scans (scan_id, timestamp, location_label, lat, lon, wifi_count, bt_count, scanner_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (scan_id, timestamp, location_label, lat, lon, wifi_count, bt_count, scanner_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
def record_wifi_observation(self, bssid: str, ssid: str, rssi: int, distance_m: float,
|
||||
channel: int, frequency: int, encryption: str,
|
||||
manufacturer: str, floor: Optional[int] = None,
|
||||
scan_id: Optional[str] = None):
|
||||
"""Record a WiFi network observation"""
|
||||
scan_id: Optional[str] = None,
|
||||
scanner_id: Optional[str] = None):
|
||||
"""Record a WiFi network observation
|
||||
|
||||
Args:
|
||||
bssid: MAC address of the WiFi network
|
||||
ssid: Network name
|
||||
rssi: Signal strength in dBm
|
||||
distance_m: Estimated distance in meters
|
||||
channel: WiFi channel
|
||||
frequency: Frequency in MHz
|
||||
encryption: Encryption type
|
||||
manufacturer: Manufacturer from OUI lookup
|
||||
floor: Floor where the device was detected
|
||||
scan_id: ID of the scan this observation belongs to
|
||||
scanner_id: ID of the scanner that detected this network
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.now().isoformat()
|
||||
@@ -209,9 +285,9 @@ class DeviceDatabase:
|
||||
|
||||
# Insert RSSI observation
|
||||
cursor.execute("""
|
||||
INSERT INTO rssi_history (device_id, timestamp, rssi, distance_m, floor, scan_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (bssid, timestamp, rssi, distance_m, floor, scan_id))
|
||||
INSERT INTO rssi_history (device_id, timestamp, rssi, distance_m, floor, scan_id, scanner_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (bssid, timestamp, rssi, distance_m, floor, scan_id, scanner_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
@@ -223,8 +299,22 @@ class DeviceDatabase:
|
||||
|
||||
def record_bluetooth_observation(self, address: str, name: str, rssi: int, distance_m: float,
|
||||
device_class: str, device_type: str, manufacturer: str,
|
||||
floor: Optional[int] = None, scan_id: Optional[str] = None):
|
||||
"""Record a Bluetooth device observation"""
|
||||
floor: Optional[int] = None, scan_id: Optional[str] = None,
|
||||
scanner_id: Optional[str] = None):
|
||||
"""Record a Bluetooth device observation
|
||||
|
||||
Args:
|
||||
address: MAC address of the Bluetooth device
|
||||
name: Device name
|
||||
rssi: Signal strength in dBm
|
||||
distance_m: Estimated distance in meters
|
||||
device_class: Bluetooth device class
|
||||
device_type: Inferred device type (Phone, Headphones, etc.)
|
||||
manufacturer: Manufacturer from OUI lookup
|
||||
floor: Floor where the device was detected
|
||||
scan_id: ID of the scan this observation belongs to
|
||||
scanner_id: ID of the scanner that detected this device
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.now().isoformat()
|
||||
@@ -252,9 +342,9 @@ class DeviceDatabase:
|
||||
|
||||
# Insert RSSI observation
|
||||
cursor.execute("""
|
||||
INSERT INTO rssi_history (device_id, timestamp, rssi, distance_m, floor, scan_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (address, timestamp, rssi, distance_m, floor, scan_id))
|
||||
INSERT INTO rssi_history (device_id, timestamp, rssi, distance_m, floor, scan_id, scanner_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (address, timestamp, rssi, distance_m, floor, scan_id, scanner_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
@@ -498,6 +588,103 @@ 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 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()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE devices SET custom_lat_offset = ?, custom_lon_offset = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?
|
||||
""", (lat_offset, lon_offset, device_id))
|
||||
conn.commit()
|
||||
|
||||
def get_device_position(self, device_id: str) -> tuple | None:
|
||||
"""Get custom position offset for a device, returns (lat_offset, lon_offset) or None"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT custom_lat_offset, custom_lon_offset FROM devices WHERE device_id = ?",
|
||||
(device_id,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row and row['custom_lat_offset'] is not None and row['custom_lon_offset'] is not None:
|
||||
return (row['custom_lat_offset'], row['custom_lon_offset'])
|
||||
return None
|
||||
|
||||
def get_all_device_positions(self) -> dict:
|
||||
"""Get all device position offsets as a dict: {device_id: {lat_offset, lon_offset}}"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT device_id, custom_lat_offset, custom_lon_offset
|
||||
FROM devices
|
||||
WHERE custom_lat_offset IS NOT NULL AND custom_lon_offset IS NOT NULL
|
||||
""")
|
||||
return {
|
||||
row['device_id']: {
|
||||
'lat_offset': row['custom_lat_offset'],
|
||||
'lon_offset': row['custom_lon_offset']
|
||||
}
|
||||
for row in cursor.fetchall()
|
||||
}
|
||||
|
||||
def clear_device_position(self, device_id: str):
|
||||
"""Clear custom position for a device (reset to RSSI-based)"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE devices SET custom_lat_offset = NULL, custom_lon_offset = NULL, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?
|
||||
""", (device_id,))
|
||||
conn.commit()
|
||||
|
||||
def get_recently_departed(self, timeout_minutes: int) -> list[dict]:
|
||||
"""Get devices not seen within timeout that haven't been notified.
|
||||
|
||||
Args:
|
||||
timeout_minutes: Minutes since last_seen to consider departed
|
||||
|
||||
Returns:
|
||||
List of device dicts that have departed but not yet notified
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cutoff = (datetime.now() - timedelta(minutes=timeout_minutes)).isoformat()
|
||||
cursor.execute("""
|
||||
SELECT device_id, device_type, name, ssid, manufacturer, last_seen
|
||||
FROM devices
|
||||
WHERE last_seen < ? AND (departure_notified = 0 OR departure_notified IS NULL)
|
||||
""", (cutoff,))
|
||||
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def mark_departure_notified(self, device_id: str):
|
||||
"""Mark device as notified about departure."""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE devices SET departure_notified = 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?
|
||||
""", (device_id,))
|
||||
conn.commit()
|
||||
|
||||
def reset_departure_notified(self, device_id: str):
|
||||
"""Reset departure notification flag when device returns."""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE devices SET departure_notified = 0, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE device_id = ?
|
||||
""", (device_id,))
|
||||
conn.commit()
|
||||
|
||||
def get_recent_activity(self, hours: int = 24) -> dict:
|
||||
"""Get activity summary for the last N hours"""
|
||||
conn = self._get_connection()
|
||||
@@ -650,6 +837,208 @@ class DeviceDatabase:
|
||||
"database_size_mb": round(db_size / 1024 / 1024, 2)
|
||||
}
|
||||
|
||||
# ==================== Peer Sync Methods ====================
|
||||
|
||||
def register_peer(self, scanner_id: str, name: str, url: str,
|
||||
floor: Optional[int] = None, latitude: Optional[float] = None,
|
||||
longitude: Optional[float] = None) -> bool:
|
||||
"""Register a peer scanner.
|
||||
|
||||
Args:
|
||||
scanner_id: Unique identifier for the peer scanner
|
||||
name: Human-readable name
|
||||
url: Base URL of the peer (e.g., http://192.168.129.9:5000)
|
||||
floor: Floor where peer scanner is located
|
||||
latitude: GPS latitude of peer
|
||||
longitude: GPS longitude of peer
|
||||
|
||||
Returns:
|
||||
True if newly registered, False if updated existing
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
# Check if peer already exists
|
||||
cursor.execute("SELECT scanner_id FROM peers WHERE scanner_id = ?", (scanner_id,))
|
||||
exists = cursor.fetchone() is not None
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO peers (scanner_id, name, url, floor, latitude, longitude, last_seen, registered_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(scanner_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
url = excluded.url,
|
||||
floor = excluded.floor,
|
||||
latitude = excluded.latitude,
|
||||
longitude = excluded.longitude,
|
||||
last_seen = excluded.last_seen
|
||||
""", (scanner_id, name, url, floor, latitude, longitude, timestamp, timestamp))
|
||||
|
||||
conn.commit()
|
||||
return not exists
|
||||
|
||||
def get_peers(self) -> list[dict]:
|
||||
"""Get all registered peers.
|
||||
|
||||
Returns:
|
||||
List of peer dictionaries with scanner_id, name, url, floor, etc.
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT * FROM peers ORDER BY registered_at")
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def get_peer(self, scanner_id: str) -> Optional[dict]:
|
||||
"""Get a specific peer by scanner_id."""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT * FROM peers WHERE scanner_id = ?", (scanner_id,))
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def remove_peer(self, scanner_id: str) -> bool:
|
||||
"""Remove a peer scanner.
|
||||
|
||||
Returns:
|
||||
True if peer was removed, False if not found
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM peers WHERE scanner_id = ?", (scanner_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def update_peer_last_seen(self, scanner_id: str):
|
||||
"""Update the last_seen timestamp for a peer."""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE peers SET last_seen = ? WHERE scanner_id = ?",
|
||||
(timestamp, scanner_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get_devices_since(self, since: Optional[str] = None) -> list[dict]:
|
||||
"""Get devices updated since a given timestamp for sync.
|
||||
|
||||
Args:
|
||||
since: ISO timestamp. If None, returns all devices with sync-relevant data.
|
||||
|
||||
Returns:
|
||||
List of device dicts with sync-relevant fields
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = """
|
||||
SELECT device_id, device_type, name, ssid, manufacturer,
|
||||
custom_label, assigned_floor, custom_lat_offset, custom_lon_offset,
|
||||
is_favorite, notes, updated_at
|
||||
FROM devices
|
||||
WHERE (custom_label IS NOT NULL OR assigned_floor IS NOT NULL
|
||||
OR custom_lat_offset IS NOT NULL OR is_favorite = 1 OR notes IS NOT NULL)
|
||||
"""
|
||||
params = []
|
||||
|
||||
if since:
|
||||
query += " AND updated_at > ?"
|
||||
params.append(since)
|
||||
|
||||
query += " ORDER BY updated_at"
|
||||
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def bulk_update_devices(self, devices: list[dict], source_scanner: str) -> int:
|
||||
"""Bulk update device metadata from peer sync.
|
||||
|
||||
Uses timestamp-based conflict resolution: newer wins.
|
||||
Only updates non-null fields from peer.
|
||||
|
||||
Args:
|
||||
devices: List of device dicts from peer
|
||||
source_scanner: Scanner ID that sent the update
|
||||
|
||||
Returns:
|
||||
Number of devices updated
|
||||
"""
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
updated_count = 0
|
||||
|
||||
for dev in devices:
|
||||
device_id = dev.get("device_id")
|
||||
if not device_id:
|
||||
continue
|
||||
|
||||
# Get existing device
|
||||
cursor.execute(
|
||||
"SELECT updated_at FROM devices WHERE device_id = ?",
|
||||
(device_id,)
|
||||
)
|
||||
existing = cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
# Check timestamp - skip if local is newer
|
||||
local_updated = existing["updated_at"] or ""
|
||||
peer_updated = dev.get("updated_at", "")
|
||||
if local_updated > peer_updated:
|
||||
continue # Local is newer, skip
|
||||
|
||||
# Merge non-null fields from peer
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if dev.get("custom_label") is not None:
|
||||
updates.append("custom_label = ?")
|
||||
params.append(dev["custom_label"])
|
||||
|
||||
if dev.get("assigned_floor") is not None:
|
||||
updates.append("assigned_floor = ?")
|
||||
params.append(dev["assigned_floor"])
|
||||
|
||||
if dev.get("custom_lat_offset") is not None:
|
||||
updates.append("custom_lat_offset = ?")
|
||||
params.append(dev["custom_lat_offset"])
|
||||
|
||||
if dev.get("custom_lon_offset") is not None:
|
||||
updates.append("custom_lon_offset = ?")
|
||||
params.append(dev["custom_lon_offset"])
|
||||
|
||||
if dev.get("is_favorite") is not None:
|
||||
updates.append("is_favorite = ?")
|
||||
params.append(1 if dev["is_favorite"] else 0)
|
||||
|
||||
if dev.get("notes") is not None:
|
||||
updates.append("notes = ?")
|
||||
params.append(dev["notes"])
|
||||
|
||||
if updates:
|
||||
# Keep the peer's updated_at to preserve timeline
|
||||
updates.append("updated_at = ?")
|
||||
params.append(peer_updated)
|
||||
params.append(device_id)
|
||||
|
||||
cursor.execute(
|
||||
f"UPDATE devices SET {', '.join(updates)} WHERE device_id = ?",
|
||||
params
|
||||
)
|
||||
if cursor.rowcount > 0:
|
||||
updated_count += 1
|
||||
else:
|
||||
# Device doesn't exist locally - we can only sync metadata for
|
||||
# devices we've seen, so skip unknown devices
|
||||
pass
|
||||
|
||||
conn.commit()
|
||||
return updated_count
|
||||
|
||||
def close(self):
|
||||
"""Close database connection"""
|
||||
if hasattr(self._local, 'conn') and self._local.conn:
|
||||
|
||||
Reference in New Issue
Block a user