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:
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user