"""Unified Flask web server for ESPILON C2 dashboard.""" import os import logging import threading import time from functools import wraps from typing import Optional from flask import Flask, render_template, send_from_directory, request, redirect, url_for, session, jsonify from werkzeug.serving import make_server from .mlat import MlatEngine # Disable Flask/Werkzeug request logging logging.getLogger('werkzeug').setLevel(logging.ERROR) class UnifiedWebServer: """ Unified Flask-based web server for ESPILON C2. Provides: - Dashboard: View connected ESP32 devices - Cameras: View live camera streams - Trilateration: Visualize BLE device positioning """ def __init__(self, host: str = "0.0.0.0", port: int = 8000, image_dir: str = "static/streams", username: str = "admin", password: str = "admin", secret_key: str = "change_this_for_prod", multilat_token: str = "multilat_secret_token", device_registry=None, mlat_engine: Optional[MlatEngine] = None): """ Initialize the unified web server. Args: host: Host to bind the server port: Port for the web server image_dir: Directory containing camera frame images username: Login username password: Login password secret_key: Flask session secret key multilat_token: Bearer token for MLAT API device_registry: DeviceRegistry instance for device listing mlat_engine: MlatEngine instance (created if None) """ self.host = host self.port = port self.image_dir = image_dir self.username = username self.password = password self.secret_key = secret_key self.multilat_token = multilat_token self.device_registry = device_registry self.mlat = mlat_engine or MlatEngine() # Ensure image directory exists c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) full_image_dir = os.path.join(c2_root, self.image_dir) os.makedirs(full_image_dir, exist_ok=True) self._app = self._create_app() self._server = None self._thread = None @property def is_running(self) -> bool: return self._thread is not None and self._thread.is_alive() def _create_app(self) -> Flask: """Create and configure the Flask application.""" # Get the c2 root directory for templates c2_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) template_dir = os.path.join(c2_root, "templates") static_dir = os.path.join(c2_root, "static") app = Flask(__name__, template_folder=template_dir, static_folder=static_dir) app.secret_key = self.secret_key # Store reference to self for route handlers web_server = self # ========== Auth Decorators ========== def require_login(f): @wraps(f) def decorated(*args, **kwargs): if not session.get("logged_in"): return redirect(url_for("login")) return f(*args, **kwargs) return decorated def require_api_auth(f): """Require session login OR Bearer token for API endpoints.""" @wraps(f) def decorated(*args, **kwargs): # Check session if session.get("logged_in"): return f(*args, **kwargs) # Check Bearer token auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] if token == web_server.mlat_token: return f(*args, **kwargs) return jsonify({"error": "Unauthorized"}), 401 return decorated # ========== Auth Routes ========== @app.route("/login", methods=["GET", "POST"]) def login(): error = None if request.method == "POST": username = request.form.get("username") password = request.form.get("password") if username == web_server.username and password == web_server.password: session["logged_in"] = True return redirect(url_for("dashboard")) else: error = "Invalid credentials." return render_template("login.html", error=error) @app.route("/logout") def logout(): session.pop("logged_in", None) return redirect(url_for("login")) # ========== Page Routes ========== @app.route("/") @require_login def index(): return redirect(url_for("dashboard")) @app.route("/dashboard") @require_login def dashboard(): return render_template("dashboard.html", active_page="dashboard") @app.route("/cameras") @require_login def cameras(): # List available camera images full_image_dir = os.path.join(c2_root, web_server.image_dir) try: image_files = sorted([ f for f in os.listdir(full_image_dir) if f.endswith(".jpg") ]) except FileNotFoundError: image_files = [] return render_template("cameras.html", active_page="cameras", image_files=image_files) @app.route("/mlat") @require_login def mlat(): return render_template("mlat.html", active_page="mlat") # ========== Static Files ========== @app.route("/streams/") @require_login def stream_image(filename): full_image_dir = os.path.join(c2_root, web_server.image_dir) return send_from_directory(full_image_dir, filename) # ========== Device API ========== @app.route("/api/devices") @require_api_auth def api_devices(): """Get list of connected devices.""" if web_server.device_registry is None: return jsonify({"error": "Device registry not available", "devices": []}) now = time.time() devices = [] for d in web_server.device_registry.all(): devices.append({ "id": d.id, "ip": d.address[0] if d.address else "unknown", "port": d.address[1] if d.address else 0, "status": d.status, "connected_at": d.connected_at, "last_seen": d.last_seen, "connected_for_seconds": round(now - d.connected_at, 1), "last_seen_ago_seconds": round(now - d.last_seen, 1) }) return jsonify({ "devices": devices, "count": len(devices) }) # ========== Camera API ========== @app.route("/api/cameras") @require_api_auth def api_cameras(): """Get list of active cameras.""" full_image_dir = os.path.join(c2_root, web_server.image_dir) try: cameras = [ f.replace(".jpg", "") for f in os.listdir(full_image_dir) if f.endswith(".jpg") ] except FileNotFoundError: cameras = [] return jsonify({"cameras": cameras, "count": len(cameras)}) # ========== Trilateration API ========== @app.route("/api/mlat/collect", methods=["POST"]) @require_api_auth def api_mlat_collect(): """ Receive multilateration data from ESP32 scanners. Expected format (text/plain): ESP_ID;(x,y);rssi ESP3;(10.0,0.0);-45 """ raw_data = request.get_data(as_text=True) count = web_server.mlat.parse_data(raw_data) # Recalculate position after new data if count > 0: web_server.mlat.calculate_position() return jsonify({ "status": "ok", "readings_processed": count }) @app.route("/api/mlat/state") @require_api_auth def api_mlat_state(): """Get current multilateration state (scanners + target).""" state = web_server.mlat.get_state() # Include latest calculation if not present if state["target"] is None and state["scanners_count"] >= 3: result = web_server.mlat.calculate_position() if "position" in result: state["target"] = { "position": result["position"], "confidence": result.get("confidence", 0), "calculated_at": result.get("calculated_at", time.time()), "age_seconds": 0 } return jsonify(state) @app.route("/api/mlat/config", methods=["GET", "POST"]) @require_api_auth def api_mlat_config(): """Get or update multilateration configuration.""" if request.method == "POST": data = request.get_json() or {} web_server.mlat.update_config( rssi_at_1m=data.get("rssi_at_1m"), path_loss_n=data.get("path_loss_n"), smoothing_window=data.get("smoothing_window") ) return jsonify({ "rssi_at_1m": web_server.mlat.rssi_at_1m, "path_loss_n": web_server.mlat.path_loss_n, "smoothing_window": web_server.mlat.smoothing_window }) @app.route("/api/mlat/clear", methods=["POST"]) @require_api_auth def api_mlat_clear(): """Clear all multilateration data.""" web_server.mlat.clear() return jsonify({"status": "ok"}) # ========== Stats API ========== @app.route("/api/stats") @require_api_auth def api_stats(): """Get overall server statistics.""" full_image_dir = os.path.join(c2_root, web_server.image_dir) try: camera_count = len([ f for f in os.listdir(full_image_dir) if f.endswith(".jpg") ]) except FileNotFoundError: camera_count = 0 device_count = 0 if web_server.device_registry: device_count = len(list(web_server.device_registry.all())) multilat_state = web_server.mlat.get_state() return jsonify({ "active_cameras": camera_count, "connected_devices": device_count, "multilateration_scanners": multilat_state["scanners_count"], "server_running": True }) return app def start(self) -> bool: """Start the web server in a background thread.""" if self.is_running: return False self._server = make_server(self.host, self.port, self._app, threaded=True) self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) self._thread.start() return True def stop(self): """Stop the web server.""" if self._server: self._server.shutdown() self._server = None self._thread = None def get_url(self) -> str: """Get the server URL.""" return f"http://{self.host}:{self.port}"