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

@@ -14,6 +14,7 @@ from ..distance import estimate_distance
from ..config import Config, get_config
from ..bluetooth_identify import identify_single_device, identify_device
from ..database import DeviceDatabase, init_database, get_database
from ..homeassistant import HAWebhooks, HAWebhookConfig
class AutoScanner:
@@ -251,6 +252,67 @@ def create_app(config: Config | None = None) -> Flask:
else:
app.config["DATABASE"] = None
# Initialize peer sync if enabled
if config.database.enabled and config.scanner.sync_interval_seconds > 0:
from ..sync import PeerSync
peer_sync = PeerSync(config, db)
app.config["PEER_SYNC"] = peer_sync
peer_sync.start()
print(f"[Sync] Peer sync enabled (interval: {config.scanner.sync_interval_seconds}s)")
else:
app.config["PEER_SYNC"] = None
# Initialize Home Assistant webhooks if enabled
ha_webhook_config = HAWebhookConfig(
enabled=config.home_assistant.enabled,
url=config.home_assistant.url,
webhook_scan=config.home_assistant.webhook_scan,
webhook_new_device=config.home_assistant.webhook_new_device,
webhook_device_gone=config.home_assistant.webhook_device_gone,
device_timeout_minutes=config.home_assistant.device_timeout_minutes
)
ha_webhooks = HAWebhooks(ha_webhook_config)
app.config["HA_WEBHOOKS"] = ha_webhooks
# Build and store scanner identity for webhooks
scanner_identity = config.get_scanner_identity()
app.config["SCANNER_IDENTITY"] = scanner_identity
if config.home_assistant.enabled:
print(f"[Home Assistant] Webhooks enabled -> {config.home_assistant.url}")
print(f"[Scanner] ID: {scanner_identity['id']} @ floor {scanner_identity['floor']}")
# Start absence checker thread if database is enabled
if config.database.enabled:
def absence_checker():
"""Background thread to detect departed devices."""
while True:
time.sleep(60) # Check every minute
try:
db = app.config.get("DATABASE")
scanner_identity = app.config.get("SCANNER_IDENTITY", {})
if db and ha_webhooks.config.enabled:
departed = db.get_recently_departed(
config.home_assistant.device_timeout_minutes
)
for device in departed:
name = device.get("custom_label") or device.get("name") or device.get("ssid") or device["device_id"]
ha_webhooks.send_device_gone(
device_id=device["device_id"],
name=name,
last_seen=device["last_seen"],
device_type=device["device_type"],
last_scanner=scanner_identity
)
db.mark_departure_notified(device["device_id"])
print(f"[HA Webhook] Device departed: {name}")
except Exception as e:
print(f"[HA Webhook] Absence checker error: {e}")
absence_thread = threading.Thread(target=absence_checker, daemon=True)
absence_thread.start()
print(f"[Home Assistant] Absence checker started (timeout: {config.home_assistant.device_timeout_minutes} min)")
# Start auto-scanner if enabled in config
if config.auto_scan.enabled:
auto_scanner.start(
@@ -810,11 +872,66 @@ def create_app(config: Config | None = None) -> Flask:
@app.route("/api/device/floors", methods=["GET"])
def api_device_floors():
"""Get all saved floor assignments"""
"""Get all saved floor assignments and position offsets"""
db = app.config.get("DATABASE")
if not db:
return jsonify({})
return jsonify(db.get_all_device_floors())
return jsonify({"floors": {}, "positions": {}})
floors = db.get_all_device_floors()
positions = db.get_all_device_positions()
return jsonify({
"floors": floors,
"positions": positions
})
@app.route("/api/device/<device_id>/position", methods=["POST"])
def api_device_position(device_id: str):
"""Set manual position for a floor-assigned device"""
db = app.config.get("DATABASE")
if not db:
return jsonify({"error": "Database not enabled"}), 503
data = request.get_json() or {}
lat_offset = data.get("lat_offset")
lon_offset = data.get("lon_offset")
# Check if device has an assigned floor (required for manual positioning)
device_floor = db.get_device_floor(device_id)
if device_floor is None:
return jsonify({
"error": "Device must have an assigned floor before manual positioning"
}), 400
# If both offsets are None/null, clear the position
if lat_offset is None and lon_offset is None:
db.clear_device_position(device_id)
return jsonify({
"status": "cleared",
"device_id": device_id,
"message": "Position reset to auto (RSSI-based)"
})
# Validate offsets
if lat_offset is None or lon_offset is None:
return jsonify({
"error": "Both lat_offset and lon_offset are required"
}), 400
try:
lat_offset = float(lat_offset)
lon_offset = float(lon_offset)
except (TypeError, ValueError):
return jsonify({"error": "Invalid offset values"}), 400
db.set_device_position(device_id, lat_offset, lon_offset)
return jsonify({
"status": "updated",
"device_id": device_id,
"lat_offset": lat_offset,
"lon_offset": lon_offset
})
@app.route("/api/scan/bt", methods=["POST"])
def api_scan_bt():
@@ -881,6 +998,10 @@ def create_app(config: Config | None = None) -> Flask:
# Record to database for historical tracking
if db:
# Check if this is a new device (for HA webhook)
existing = db.get_device(addr)
is_new_device = existing is None
db.record_bluetooth_observation(
address=addr,
name=name,
@@ -893,6 +1014,22 @@ def create_app(config: Config | None = None) -> Flask:
scan_id=None # Live tracking, no scan_id
)
# Reset departure notification flag (device is present)
db.reset_departure_notified(addr)
# Send new device webhook to HA
ha_webhooks = app.config.get("HA_WEBHOOKS")
scanner_identity = app.config.get("SCANNER_IDENTITY", {})
if ha_webhooks and ha_webhooks.config.enabled and is_new_device:
ha_webhooks.send_new_device(
device_id=addr,
name=name,
device_type="bluetooth",
scanner=scanner_identity,
rssi=rssi,
distance_m=dist
)
# Get saved floor from database
saved_floor = saved_floors.get(addr)
@@ -909,6 +1046,22 @@ def create_app(config: Config | None = None) -> Flask:
"height_m": None
})
# Send scan results to Home Assistant
ha_webhooks = app.config.get("HA_WEBHOOKS")
scanner_identity = app.config.get("SCANNER_IDENTITY", {})
if ha_webhooks and ha_webhooks.config.enabled and response_data["bluetooth_devices"]:
ha_webhooks.send_scan_results(
devices=[{
"id": d["address"],
"name": d["name"],
"rssi": d["rssi"],
"distance": d["estimated_distance_m"],
"floor": d.get("floor")
} for d in response_data["bluetooth_devices"]],
scanner=scanner_identity,
scan_type="bluetooth"
)
return jsonify(response_data)
# ==================== Historical Data API ====================
@@ -1099,6 +1252,154 @@ def create_app(config: Config | None = None) -> Flask:
result = db.cleanup_old_data(retention_days)
return jsonify(result)
# ==================== Peer Sync API ====================
@app.route("/api/peers", methods=["GET"])
def api_get_peers():
"""Get list of known peers and this scanner's identity"""
db = app.config.get("DATABASE")
if not db:
return jsonify({"error": "Database not enabled"}), 503
peers = db.get_peers()
peer_sync = app.config.get("PEER_SYNC")
return jsonify({
"this_scanner": app.config["SCANNER_IDENTITY"],
"peers": peers,
"sync_status": peer_sync.get_status() if peer_sync else None
})
@app.route("/api/peers/register", methods=["POST"])
def api_register_peer():
"""Register a peer scanner (called by other scanners)"""
rf_config = app.config["RF_CONFIG"]
if not rf_config.scanner.accept_registrations:
return jsonify({"error": "Registration disabled on this scanner"}), 403
db = app.config.get("DATABASE")
if not db:
return jsonify({"error": "Database not enabled"}), 503
data = request.get_json() or {}
# Validate required fields
peer_id = data.get("id")
peer_url = data.get("url")
if not peer_id or not peer_url:
return jsonify({"error": "Missing required fields: id, url"}), 400
# Register the peer
is_new = db.register_peer(
scanner_id=peer_id,
name=data.get("name", peer_id),
url=peer_url,
floor=data.get("floor"),
latitude=data.get("latitude"),
longitude=data.get("longitude")
)
action = "registered" if is_new else "updated"
print(f"[Sync] Peer {action}: {peer_id} at {peer_url}")
# Auto-register back with the peer (mutual registration)
peer_sync = app.config.get("PEER_SYNC")
if peer_sync and is_new:
try:
peer_sync.register_with_peer(peer_url)
print(f"[Sync] Registered back with peer {peer_id}")
except Exception as e:
print(f"[Sync] Failed to register back with {peer_id}: {e}")
return jsonify({
"status": action,
"this_scanner": app.config["SCANNER_IDENTITY"],
"known_peers": db.get_peers()
})
@app.route("/api/peers/<scanner_id>", methods=["DELETE"])
def api_remove_peer(scanner_id: str):
"""Remove a peer scanner"""
db = app.config.get("DATABASE")
if not db:
return jsonify({"error": "Database not enabled"}), 503
removed = db.remove_peer(scanner_id)
if removed:
print(f"[Sync] Peer removed: {scanner_id}")
return jsonify({"status": "removed", "scanner_id": scanner_id})
else:
return jsonify({"error": "Peer not found"}), 404
@app.route("/api/sync/devices", methods=["GET"])
def api_sync_devices_get():
"""Get devices for sync (called by peers)"""
db = app.config.get("DATABASE")
if not db:
return jsonify({"error": "Database not enabled"}), 503
since = request.args.get("since")
devices = db.get_devices_since(since)
return jsonify({
"scanner_id": app.config["SCANNER_IDENTITY"]["id"],
"timestamp": datetime.now().isoformat(),
"devices": devices
})
@app.route("/api/sync/devices", methods=["POST"])
def api_sync_devices_post():
"""Receive device updates from a peer"""
db = app.config.get("DATABASE")
if not db:
return jsonify({"error": "Database not enabled"}), 503
data = request.get_json() or {}
devices = data.get("devices", [])
source_scanner = data.get("source_scanner", "unknown")
updated = db.bulk_update_devices(devices, source_scanner)
return jsonify({
"status": "synced",
"updated": updated,
"received": len(devices)
})
@app.route("/api/sync/trigger", methods=["POST"])
def api_sync_trigger():
"""Manually trigger a sync with all peers"""
peer_sync = app.config.get("PEER_SYNC")
if not peer_sync:
return jsonify({"error": "Peer sync not enabled"}), 503
db = app.config.get("DATABASE")
peers = db.get_peers() if db else []
results = []
for peer in peers:
peer_id = peer["scanner_id"]
peer_url = peer["url"]
try:
updated = peer_sync.sync_devices_from_peer(peer_url)
peer_sync.push_devices_to_peer(peer_url)
results.append({
"peer_id": peer_id,
"status": "success",
"devices_updated": updated
})
except Exception as e:
results.append({
"peer_id": peer_id,
"status": "error",
"error": str(e)
})
return jsonify({
"status": "completed",
"results": results
})
return app
@@ -1133,11 +1434,16 @@ def run_server(
log_dir = config.get_data_dir() / "logs"
add_request_logging_middleware(app, log_dir)
scanner_identity = config.get_scanner_identity()
print(f"\n{'='*60}")
print("RF Mapper Web Interface")
print(f"{'='*60}")
print(f"Scanner ID: {scanner_identity['id']}")
print(f"Scanner Name: {scanner_identity['name']}")
print(f"Scanner Floor: {scanner_identity['floor']}")
print(f"Config file: {config._config_path}")
print(f"GPS Position: {config.gps.latitude}, {config.gps.longitude}")
print(f"GPS Position: {scanner_identity['latitude']}, {scanner_identity['longitude']}")
print(f"Data directory: {config.get_data_dir()}")
if config.auto_scan.enabled:
print(f"Auto-scan: ENABLED (every {config.auto_scan.interval_minutes} min)")