diff --git a/src/rf_mapper/web/app.py b/src/rf_mapper/web/app.py index 77b6533..3475fec 100644 --- a/src/rf_mapper/web/app.py +++ b/src/rf_mapper/web/app.py @@ -7,7 +7,8 @@ import time from datetime import datetime from pathlib import Path -from flask import Flask, jsonify, render_template, request +from flask import Flask, current_app, jsonify, render_template, request +from flask_socketio import SocketIO, emit from ..scanner import RFScanner from ..distance import estimate_distance @@ -16,6 +17,29 @@ from ..bluetooth_identify import identify_single_device, identify_device from ..database import DeviceDatabase, init_database, get_database from ..homeassistant import HAWebhooks, HAWebhookConfig +# Module-level SocketIO instance +socketio = SocketIO() + + +def broadcast_scan_update(app: Flask, devices: list[dict], scan_type: str = "bluetooth"): + """Broadcast scan results to all connected WebSocket clients.""" + sio = app.config.get("SOCKETIO") + if not sio: + return + + scanner_identity = app.config.get("SCANNER_IDENTITY", {}) + + sio.emit( + "scan_update", + { + "type": scan_type, + "timestamp": datetime.now().isoformat(), + "scanner_id": scanner_identity.get("id", "unknown"), + "devices": devices, + }, + namespace="/ws/scan", + ) + class AutoScanner: """Background scanner that runs periodic scans""" @@ -213,6 +237,10 @@ def create_app(config: Config | None = None) -> Flask: # Store config reference app.config["RF_CONFIG"] = config + # Initialize SocketIO with threading mode (compatible with existing threads) + socketio.init_app(app, cors_allowed_origins="*", async_mode="threading") + app.config["SOCKETIO"] = socketio + # Data directory from config app.config["DATA_DIR"] = config.get_data_dir() app.config["DATA_DIR"].mkdir(parents=True, exist_ok=True) @@ -320,6 +348,31 @@ def create_app(config: Config | None = None) -> Flask: location_label=config.auto_scan.location_label ) + # ==================== WebSocket Event Handlers ==================== + + @socketio.on("connect", namespace="/ws/scan") + def ws_connect(): + """Handle client connection.""" + scanner_id = current_app.config.get("SCANNER_IDENTITY", {}).get("id", "unknown") + emit("connected", {"scanner_id": scanner_id}) + print(f"[WS] Client connected: {request.sid}") + + @socketio.on("disconnect", namespace="/ws/scan") + def ws_disconnect(): + """Handle client disconnection.""" + print(f"[WS] Client disconnected: {request.sid}") + + @socketio.on("subscribe_floor", namespace="/ws/scan") + def ws_subscribe_floor(data): + """Subscribe to floor-specific updates.""" + from flask_socketio import join_room + + floor = data.get("floor", "all") + join_room(f"floor_{floor}") + emit("subscribed", {"floor": floor}) + + # ==================== HTTP Routes ==================== + @app.route("/") def index(): """Main dashboard page""" @@ -1072,6 +1125,9 @@ def create_app(config: Config | None = None) -> Flask: scan_type="bluetooth" ) + # Broadcast to WebSocket clients + broadcast_scan_update(current_app, response_data["bluetooth_devices"], "bluetooth") + return jsonify(response_data) # ==================== Historical Data API ==================== @@ -1476,10 +1532,12 @@ def run_server( if log_requests: print(f"Request logging: ENABLED") print(f"Log output: {config.get_data_dir() / 'logs'}") + print(f"WebSocket: ENABLED (namespace /ws/scan)") print(f"{'='*60}") print(f"Server running at: http://{host}:{port}") print(f"Local access: http://localhost:{port}") print(f"Network access: http://:{port}") print(f"{'='*60}\n") - app.run(host=host, port=port, debug=debug) + # Use socketio.run() for WebSocket support + socketio.run(app, host=host, port=port, debug=debug, allow_unsafe_werkzeug=True)