From b931c81a133ee583af2c7a1fb9555bd020364afa Mon Sep 17 00:00:00 2001 From: Eun0us Date: Mon, 19 Jan 2026 13:09:09 +0100 Subject: [PATCH] =?UTF-8?q?=CE=B5=20-=20C2=20implementation=20camera?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/c2/.env.example | 47 +++++++ tools/c2/c3po.py | 2 +- tools/c2/camera/__init__.py | 3 + tools/c2/camera/config.py | 62 ++++++++++ tools/c2/camera/server.py | 122 ++++++++++++++++++ tools/c2/camera/udp_receiver.py | 188 ++++++++++++++++++++++++++++ tools/c2/camera/web_server.py | 158 ++++++++++++++++++++++++ tools/c2/cli/cli.py | 74 ++++++++++- tools/c2/templates/index.html | 212 ++++++++++++++++++++++++++++++++ tools/c2/templates/login.html | 115 +++++++++++++++++ 10 files changed, 979 insertions(+), 4 deletions(-) create mode 100644 tools/c2/.env.example create mode 100644 tools/c2/camera/__init__.py create mode 100644 tools/c2/camera/config.py create mode 100644 tools/c2/camera/server.py create mode 100644 tools/c2/camera/udp_receiver.py create mode 100644 tools/c2/camera/web_server.py create mode 100644 tools/c2/templates/index.html create mode 100644 tools/c2/templates/login.html diff --git a/tools/c2/.env.example b/tools/c2/.env.example new file mode 100644 index 0000000..1bcd23f --- /dev/null +++ b/tools/c2/.env.example @@ -0,0 +1,47 @@ +# ESPILON C2 Configuration +# Copy this file to .env and adjust values + +# =================== +# C2 Server +# =================== +C2_HOST=0.0.0.0 +C2_PORT=2626 + +# =================== +# Camera Server +# =================== +# UDP receiver for camera frames +UDP_HOST=0.0.0.0 +UDP_PORT=5000 +UDP_BUFFER_SIZE=65535 + +# Web server for viewing streams +WEB_HOST=0.0.0.0 +WEB_PORT=8000 + +# =================== +# Security +# =================== +# Token for authenticating camera frames (must match ESP firmware) +CAMERA_SECRET_TOKEN=Sup3rS3cretT0k3n + +# Flask session secret (change in production!) +FLASK_SECRET_KEY=change_this_for_prod + +# Web interface credentials +WEB_USERNAME=admin +WEB_PASSWORD=admin + +# =================== +# Storage +# =================== +# Directory for camera frame storage (relative to c2 root) +IMAGE_DIR=static/streams + +# =================== +# Video Recording +# =================== +VIDEO_ENABLED=true +VIDEO_PATH=static/streams/record.avi +VIDEO_FPS=10 +VIDEO_CODEC=MJPG diff --git a/tools/c2/c3po.py b/tools/c2/c3po.py index 6d2b1c1..c8e82c2 100644 --- a/tools/c2/c3po.py +++ b/tools/c2/c3po.py @@ -93,7 +93,7 @@ def main(): $$$$$$$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ $$$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$\ -$$ _____|$$ __$$\ $$ __$$\\_$$ _|$$ | $$ __$$\ $$$\ $$ | $$ __$$\ $$ __$$\ +$$ _____|$$ __$$\ $$ __$$\ \_$$ _|$$ | $$ __$$\ $$$\ $$ | $$ __$$\ $$ __$$\ $$ | $$ / \__|$$ | $$ | $$ | $$ | $$ / $$ |$$$$\ $$ | $$ / \__|\__/ $$ | $$$$$\ \$$$$$$\ $$$$$$$ | $$ | $$ | $$ | $$ |$$ $$\$$ | $$ | $$$$$$ | $$ __| \____$$\ $$ ____/ $$ | $$ | $$ | $$ |$$ \$$$$ | $$ | $$ ____/ diff --git a/tools/c2/camera/__init__.py b/tools/c2/camera/__init__.py new file mode 100644 index 0000000..71438d4 --- /dev/null +++ b/tools/c2/camera/__init__.py @@ -0,0 +1,3 @@ +from .server import CameraServer + +__all__ = ["CameraServer"] diff --git a/tools/c2/camera/config.py b/tools/c2/camera/config.py new file mode 100644 index 0000000..641999a --- /dev/null +++ b/tools/c2/camera/config.py @@ -0,0 +1,62 @@ +"""Configuration loader for camera server module - reads from .env file.""" + +import os +from pathlib import Path +from dotenv import load_dotenv + +# Load .env file from c2 root directory +C2_ROOT = Path(__file__).parent.parent +ENV_FILE = C2_ROOT / ".env" + +if ENV_FILE.exists(): + load_dotenv(ENV_FILE) +else: + # Try .env.example as fallback for development + example_env = C2_ROOT / ".env.example" + if example_env.exists(): + load_dotenv(example_env) + + +def _get_bool(key: str, default: bool = False) -> bool: + """Get boolean value from environment.""" + val = os.getenv(key, str(default)).lower() + return val in ("true", "1", "yes", "on") + + +def _get_int(key: str, default: int) -> int: + """Get integer value from environment.""" + try: + return int(os.getenv(key, default)) + except ValueError: + return default + + +# C2 Server +C2_HOST = os.getenv("C2_HOST", "0.0.0.0") +C2_PORT = _get_int("C2_PORT", 2626) + +# UDP Server configuration +UDP_HOST = os.getenv("UDP_HOST", "0.0.0.0") +UDP_PORT = _get_int("UDP_PORT", 5000) +UDP_BUFFER_SIZE = _get_int("UDP_BUFFER_SIZE", 65535) + +# Flask Web Server configuration +WEB_HOST = os.getenv("WEB_HOST", "0.0.0.0") +WEB_PORT = _get_int("WEB_PORT", 8000) + +# Security +SECRET_TOKEN = os.getenv("CAMERA_SECRET_TOKEN", "Sup3rS3cretT0k3n").encode() +FLASK_SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "change_this_for_prod") + +# Credentials +DEFAULT_USERNAME = os.getenv("WEB_USERNAME", "admin") +DEFAULT_PASSWORD = os.getenv("WEB_PASSWORD", "admin") + +# Storage paths +IMAGE_DIR = os.getenv("IMAGE_DIR", "static/streams") + +# Video recording +VIDEO_ENABLED = _get_bool("VIDEO_ENABLED", True) +VIDEO_PATH = os.getenv("VIDEO_PATH", "static/streams/record.avi") +VIDEO_FPS = _get_int("VIDEO_FPS", 10) +VIDEO_CODEC = os.getenv("VIDEO_CODEC", "MJPG") diff --git a/tools/c2/camera/server.py b/tools/c2/camera/server.py new file mode 100644 index 0000000..3173107 --- /dev/null +++ b/tools/c2/camera/server.py @@ -0,0 +1,122 @@ +"""Main camera server combining UDP receiver and web server.""" + +from typing import Optional, Callable + +from .config import UDP_HOST, UDP_PORT, WEB_HOST, WEB_PORT, IMAGE_DIR, DEFAULT_USERNAME, DEFAULT_PASSWORD +from .udp_receiver import UDPReceiver +from .web_server import WebServer + + +class CameraServer: + """ + Combined camera server that manages both: + - UDP receiver for incoming camera frames from ESP devices + - Web server for viewing the camera streams + """ + + def __init__(self, + udp_host: str = UDP_HOST, + udp_port: int = UDP_PORT, + web_host: str = WEB_HOST, + web_port: int = WEB_PORT, + image_dir: str = IMAGE_DIR, + username: str = DEFAULT_USERNAME, + password: str = DEFAULT_PASSWORD, + on_frame: Optional[Callable] = None): + """ + Initialize the camera server. + + Args: + udp_host: Host to bind UDP receiver + udp_port: Port for UDP receiver + web_host: Host to bind web server + web_port: Port for web server + image_dir: Directory to store camera frames + username: Web interface username + password: Web interface password + on_frame: Optional callback when frame is received (camera_id, frame, addr) + """ + self.udp_receiver = UDPReceiver( + host=udp_host, + port=udp_port, + image_dir=image_dir, + on_frame=on_frame + ) + + self.web_server = WebServer( + host=web_host, + port=web_port, + image_dir=image_dir, + username=username, + password=password + ) + + @property + def is_running(self) -> bool: + """Check if both servers are running.""" + return self.udp_receiver.is_running and self.web_server.is_running + + @property + def udp_running(self) -> bool: + return self.udp_receiver.is_running + + @property + def web_running(self) -> bool: + return self.web_server.is_running + + def start(self) -> dict: + """ + Start both UDP receiver and web server. + + Returns: + dict with status of each server + """ + results = { + "udp": {"started": False, "host": self.udp_receiver.host, "port": self.udp_receiver.port}, + "web": {"started": False, "host": self.web_server.host, "port": self.web_server.port} + } + + if self.udp_receiver.start(): + results["udp"]["started"] = True + + if self.web_server.start(): + results["web"]["started"] = True + results["web"]["url"] = self.web_server.get_url() + + return results + + def stop(self) -> dict: + """ + Stop both servers. + + Returns: + dict with stop status + """ + self.udp_receiver.stop() + self.web_server.stop() + + return { + "udp": {"stopped": True}, + "web": {"stopped": True} + } + + def get_status(self) -> dict: + """Get status of both servers.""" + return { + "udp": { + "running": self.udp_receiver.is_running, + "host": self.udp_receiver.host, + "port": self.udp_receiver.port, + **self.udp_receiver.get_stats() + }, + "web": { + "running": self.web_server.is_running, + "host": self.web_server.host, + "port": self.web_server.port, + "url": self.web_server.get_url() if self.web_server.is_running else None + } + } + + def get_active_cameras(self) -> list: + """Get list of active camera IDs.""" + return self.udp_receiver.active_cameras diff --git a/tools/c2/camera/udp_receiver.py b/tools/c2/camera/udp_receiver.py new file mode 100644 index 0000000..95f6954 --- /dev/null +++ b/tools/c2/camera/udp_receiver.py @@ -0,0 +1,188 @@ +"""UDP server for receiving camera frames from ESP devices.""" + +import os +import socket +import threading +import cv2 +import numpy as np +from typing import Optional, Callable + +from .config import ( + UDP_HOST, UDP_PORT, UDP_BUFFER_SIZE, + SECRET_TOKEN, IMAGE_DIR, + VIDEO_ENABLED, VIDEO_PATH, VIDEO_FPS, VIDEO_CODEC +) + + +class UDPReceiver: + """Receives JPEG frames via UDP from ESP camera devices.""" + + def __init__(self, + host: str = UDP_HOST, + port: int = UDP_PORT, + image_dir: str = IMAGE_DIR, + on_frame: Optional[Callable] = None): + self.host = host + self.port = port + self.image_dir = image_dir + self.on_frame = on_frame # Callback when frame received + + self._sock: Optional[socket.socket] = None + self._thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + + # Video recording + self._video_writer: Optional[cv2.VideoWriter] = None + self._video_size: Optional[tuple] = None + + # Statistics + self.frames_received = 0 + self.invalid_tokens = 0 + self.decode_errors = 0 + + # Active cameras tracking + self._active_cameras: dict = {} # {camera_id: last_frame_time} + + os.makedirs(self.image_dir, exist_ok=True) + + @property + def is_running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + @property + def active_cameras(self) -> list: + """Returns list of active camera identifiers.""" + return list(self._active_cameras.keys()) + + def start(self) -> bool: + """Start the UDP receiver thread.""" + if self.is_running: + return False + + self._stop_event.clear() + self._thread = threading.Thread(target=self._receive_loop, daemon=True) + self._thread.start() + return True + + def stop(self): + """Stop the UDP receiver and cleanup.""" + self._stop_event.set() + + if self._sock: + try: + self._sock.close() + except Exception: + pass + self._sock = None + + if self._video_writer is not None: + self._video_writer.release() + self._video_writer = None + + # Clean up frame files + self._cleanup_frames() + + self._active_cameras.clear() + self.frames_received = 0 + + def _cleanup_frames(self): + """Remove all .jpg files from image directory.""" + try: + for f in os.listdir(self.image_dir): + if f.endswith(".jpg"): + os.remove(os.path.join(self.image_dir, f)) + except Exception: + pass + + def _receive_loop(self): + """Main UDP receive loop.""" + self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._sock.bind((self.host, self.port)) + self._sock.settimeout(1.0) + + while not self._stop_event.is_set(): + try: + data, addr = self._sock.recvfrom(UDP_BUFFER_SIZE) + except socket.timeout: + continue + except OSError: + break + + # Validate token + if not data.startswith(SECRET_TOKEN): + self.invalid_tokens += 1 + continue + + # Remove token prefix + frame_data = data[len(SECRET_TOKEN):] + + # Decode JPEG + frame = self._decode_frame(frame_data) + if frame is None: + self.decode_errors += 1 + continue + + self.frames_received += 1 + camera_id = f"{addr[0]}_{addr[1]}" + self._active_cameras[camera_id] = True + + # Save frame + self._save_frame(camera_id, frame) + + # Record video if enabled + if VIDEO_ENABLED: + self._record_frame(frame) + + # Callback + if self.on_frame: + self.on_frame(camera_id, frame, addr) + + # Cleanup + if self._sock: + self._sock.close() + self._sock = None + + if self._video_writer: + self._video_writer.release() + self._video_writer = None + + def _decode_frame(self, data: bytes) -> Optional[np.ndarray]: + """Decode JPEG data to OpenCV frame.""" + try: + npdata = np.frombuffer(data, np.uint8) + frame = cv2.imdecode(npdata, cv2.IMREAD_COLOR) + return frame + except Exception: + return None + + def _save_frame(self, camera_id: str, frame: np.ndarray): + """Save frame as JPEG file.""" + try: + filepath = os.path.join(self.image_dir, f"{camera_id}.jpg") + cv2.imwrite(filepath, frame) + except Exception: + pass + + def _record_frame(self, frame: np.ndarray): + """Record frame to video file.""" + if self._video_writer is None: + self._video_size = (frame.shape[1], frame.shape[0]) + fourcc = cv2.VideoWriter_fourcc(*VIDEO_CODEC) + video_path = os.path.join(os.path.dirname(self.image_dir), VIDEO_PATH.split('/')[-1]) + self._video_writer = cv2.VideoWriter( + video_path, fourcc, VIDEO_FPS, self._video_size + ) + + if self._video_writer and self._video_writer.isOpened(): + self._video_writer.write(frame) + + def get_stats(self) -> dict: + """Return receiver statistics.""" + return { + "running": self.is_running, + "frames_received": self.frames_received, + "invalid_tokens": self.invalid_tokens, + "decode_errors": self.decode_errors, + "active_cameras": len(self._active_cameras) + } diff --git a/tools/c2/camera/web_server.py b/tools/c2/camera/web_server.py new file mode 100644 index 0000000..2ccc9e6 --- /dev/null +++ b/tools/c2/camera/web_server.py @@ -0,0 +1,158 @@ +"""Flask web server for camera stream display.""" + +import os +import logging +import threading +from flask import Flask, render_template, send_from_directory, request, redirect, url_for, session, jsonify +from werkzeug.serving import make_server + +from .config import ( + WEB_HOST, WEB_PORT, FLASK_SECRET_KEY, + DEFAULT_USERNAME, DEFAULT_PASSWORD, IMAGE_DIR +) + +# Disable Flask/Werkzeug request logging +logging.getLogger('werkzeug').setLevel(logging.ERROR) + + +class WebServer: + """Flask-based web server for viewing camera streams.""" + + def __init__(self, + host: str = WEB_HOST, + port: int = WEB_PORT, + image_dir: str = IMAGE_DIR, + username: str = DEFAULT_USERNAME, + password: str = DEFAULT_PASSWORD): + self.host = host + self.port = port + self.image_dir = image_dir + self.username = username + self.password = password + + 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 = FLASK_SECRET_KEY + + # Store reference to self for route handlers + web_server = self + + @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("index")) + 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")) + + @app.route("/") + def index(): + if not session.get("logged_in"): + return redirect(url_for("login")) + + # 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 = [] + + if not image_files: + image_files = [] + + return render_template("index.html", image_files=image_files) + + @app.route("/streams/") + 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("/api/cameras") + def api_cameras(): + """API endpoint to get list of active cameras.""" + if not session.get("logged_in"): + return jsonify({"error": "Unauthorized"}), 401 + + 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}) + + @app.route("/api/stats") + def api_stats(): + """API endpoint for server statistics.""" + if not session.get("logged_in"): + return jsonify({"error": "Unauthorized"}), 401 + + 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 + + return jsonify({ + "active_cameras": camera_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}" diff --git a/tools/c2/cli/cli.py b/tools/c2/cli/cli.py index 085267f..8d561f1 100644 --- a/tools/c2/cli/cli.py +++ b/tools/c2/cli/cli.py @@ -1,11 +1,13 @@ import readline import os import time +from typing import Optional from utils.display import Display from cli.help import HelpManager from core.transport import Transport from proto.c2_pb2 import Command +from camera import CameraServer DEV_MODE = True @@ -17,7 +19,10 @@ class CLI: self.groups = groups self.transport = transport self.help_manager = HelpManager(commands, DEV_MODE) - self.active_commands = {} # {request_id: {"device_id": ..., "command_name": ..., "start_time": ..., "status": "running"}} + self.active_commands = {} # {request_id: {"device_id": ..., "command_name": ..., "start_time": ..., "status": "running"}} + + # Camera server instance + self.camera_server: Optional[CameraServer] = None readline.parse_and_bind("tab: complete") readline.set_completer(self._complete) @@ -31,7 +36,7 @@ class CLI: options = [] if len(parts) == 1: - options = ["send", "list", "group", "help", "clear", "exit", "active_commands"] + options = ["send", "list", "group", "help", "clear", "exit", "active_commands", "camera"] elif parts[0] == "send": if len(parts) == 2: # Completing target (device ID, 'all', 'group') @@ -42,6 +47,10 @@ class CLI: options = self.commands.list() # Add more logic here if commands have arguments that can be tab-completed + elif parts[0] == "camera": + if len(parts) == 2: + options = ["start", "stop", "status"] + elif parts[0] == "group": if len(parts) == 2: # Completing group action options = ["add", "remove", "list", "show"] @@ -93,11 +102,15 @@ class CLI: if action == "send": self._handle_send(parts) continue - + if action == "active_commands": self._handle_active_commands() continue + if action == "camera": + self._handle_camera(parts[1:]) + continue + Display.error("Unknown command") # ================= HANDLERS ================= @@ -287,3 +300,58 @@ class CLI: cmd_info["status"], elapsed_time ]) + + def _handle_camera(self, parts): + if not parts: + Display.error("Usage: camera ") + return + + cmd = parts[0] + + if cmd == "start": + if self.camera_server and self.camera_server.is_running: + Display.system_message("Camera server is already running.") + return + + self.camera_server = CameraServer() + result = self.camera_server.start() + + if result["udp"]["started"]: + Display.system_message(f"UDP receiver started on {result['udp']['host']}:{result['udp']['port']}") + else: + Display.error("UDP receiver failed to start (already running?)") + + if result["web"]["started"]: + Display.system_message(f"Web server started at {result['web']['url']}") + else: + Display.error("Web server failed to start (already running?)") + + elif cmd == "stop": + if not self.camera_server: + Display.system_message("Camera server is not running.") + return + + self.camera_server.stop() + Display.system_message("Camera server stopped.") + self.camera_server = None + + elif cmd == "status": + if not self.camera_server: + Display.system_message("Camera server is not running.") + return + + status = self.camera_server.get_status() + + Display.system_message("Camera Server Status:") + Display.system_message(f" UDP Receiver: {'Running' if status['udp']['running'] else 'Stopped'}") + if status['udp']['running']: + Display.system_message(f" - Host: {status['udp']['host']}:{status['udp']['port']}") + Display.system_message(f" - Frames received: {status['udp']['frames_received']}") + Display.system_message(f" - Active cameras: {status['udp']['active_cameras']}") + + Display.system_message(f" Web Server: {'Running' if status['web']['running'] else 'Stopped'}") + if status['web']['running']: + Display.system_message(f" - URL: {status['web']['url']}") + + else: + Display.error("Invalid camera command. Use: start, stop, status") diff --git a/tools/c2/templates/index.html b/tools/c2/templates/index.html new file mode 100644 index 0000000..92d3956 --- /dev/null +++ b/tools/c2/templates/index.html @@ -0,0 +1,212 @@ + + + + + + Cameras - ESPILON + + + +
+ +
+
+
+ {{ image_files|length }} camera(s) +
+ Logout +
+
+ +
+
+
Cameras Live Feed
+
+ + {% if image_files %} +
+ {% for img in image_files %} +
+
+ {{ img.replace('.jpg', '').replace('_', ':') }} + LIVE +
+
+ +
+
+ {% endfor %} +
+ {% else %} +
+

No active cameras

+

Waiting for ESP devices to send frames on UDP port 5000

+
+ {% endif %} +
+ + + + diff --git a/tools/c2/templates/login.html b/tools/c2/templates/login.html new file mode 100644 index 0000000..6e9aae7 --- /dev/null +++ b/tools/c2/templates/login.html @@ -0,0 +1,115 @@ + + + + + + Login - ESPILON + + + + + +