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:
User
2026-02-01 03:19:04 +01:00
parent bf455f074b
commit fa5178a5be
5 changed files with 1030 additions and 74 deletions

View File

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