"""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 with recording - 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, camera_receiver=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) camera_receiver: UDPReceiver instance for camera control """ 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() self.camera_receiver = camera_receiver # 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 def set_camera_receiver(self, receiver): """Set the camera receiver after initialization.""" self.camera_receiver = receiver @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.""" 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 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): @wraps(f) def decorated(*args, **kwargs): if session.get("logged_in"): return f(*args, **kwargs) auth_header = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] if token == web_server.multilat_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(): 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) @app.route("/recordings/") @require_login def download_recording(filename): recordings_dir = os.path.join(c2_root, "static", "recordings") return send_from_directory(recordings_dir, filename, as_attachment=True) # ========== Device API ========== @app.route("/api/devices") @require_api_auth def api_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(): 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 = [] # Add recording status if receiver available result = {"cameras": [], "count": len(cameras)} for cam_id in cameras: cam_info = {"id": cam_id, "recording": False} if web_server.camera_receiver: status = web_server.camera_receiver.get_recording_status(cam_id) cam_info["recording"] = status.get("recording", False) cam_info["filename"] = status.get("filename") result["cameras"].append(cam_info) result["count"] = len(result["cameras"]) return jsonify(result) # ========== Recording API ========== @app.route("/api/recording/start/", methods=["POST"]) @require_api_auth def api_recording_start(camera_id): if not web_server.camera_receiver: return jsonify({"error": "Camera receiver not available"}), 503 result = web_server.camera_receiver.start_recording(camera_id) if "error" in result: return jsonify(result), 400 return jsonify(result) @app.route("/api/recording/stop/", methods=["POST"]) @require_api_auth def api_recording_stop(camera_id): if not web_server.camera_receiver: return jsonify({"error": "Camera receiver not available"}), 503 result = web_server.camera_receiver.stop_recording(camera_id) if "error" in result: return jsonify(result), 400 return jsonify(result) @app.route("/api/recording/status") @require_api_auth def api_recording_status(): if not web_server.camera_receiver: return jsonify({"error": "Camera receiver not available"}), 503 camera_id = request.args.get("camera_id") return jsonify(web_server.camera_receiver.get_recording_status(camera_id)) @app.route("/api/recordings") @require_api_auth def api_recordings_list(): if not web_server.camera_receiver: return jsonify({"recordings": []}) return jsonify({"recordings": web_server.camera_receiver.list_recordings()}) # ========== Trilateration API ========== @app.route("/api/mlat/collect", methods=["POST"]) @require_api_auth def api_mlat_collect(): raw_data = request.get_data(as_text=True) count = web_server.mlat.parse_data(raw_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(): state = web_server.mlat.get_state() 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(): 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(): web_server.mlat.clear() return jsonify({"status": "ok"}) # ========== Stats API ========== @app.route("/api/stats") @require_api_auth def api_stats(): 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}"