ε - C2 implementation camera
This commit is contained in:
parent
a9151d4fd2
commit
b931c81a13
47
tools/c2/.env.example
Normal file
47
tools/c2/.env.example
Normal file
@ -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
|
||||
@ -93,7 +93,7 @@ def main():
|
||||
$$$$$$$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\
|
||||
|
||||
$$$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$\
|
||||
$$ _____|$$ __$$\ $$ __$$\\_$$ _|$$ | $$ __$$\ $$$\ $$ | $$ __$$\ $$ __$$\
|
||||
$$ _____|$$ __$$\ $$ __$$\ \_$$ _|$$ | $$ __$$\ $$$\ $$ | $$ __$$\ $$ __$$\
|
||||
$$ | $$ / \__|$$ | $$ | $$ | $$ | $$ / $$ |$$$$\ $$ | $$ / \__|\__/ $$ |
|
||||
$$$$$\ \$$$$$$\ $$$$$$$ | $$ | $$ | $$ | $$ |$$ $$\$$ | $$ | $$$$$$ |
|
||||
$$ __| \____$$\ $$ ____/ $$ | $$ | $$ | $$ |$$ \$$$$ | $$ | $$ ____/
|
||||
|
||||
3
tools/c2/camera/__init__.py
Normal file
3
tools/c2/camera/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .server import CameraServer
|
||||
|
||||
__all__ = ["CameraServer"]
|
||||
62
tools/c2/camera/config.py
Normal file
62
tools/c2/camera/config.py
Normal file
@ -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")
|
||||
122
tools/c2/camera/server.py
Normal file
122
tools/c2/camera/server.py
Normal file
@ -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
|
||||
188
tools/c2/camera/udp_receiver.py
Normal file
188
tools/c2/camera/udp_receiver.py
Normal file
@ -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)
|
||||
}
|
||||
158
tools/c2/camera/web_server.py
Normal file
158
tools/c2/camera/web_server.py
Normal file
@ -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/<filename>")
|
||||
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}"
|
||||
@ -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 <start|stop|status>")
|
||||
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")
|
||||
|
||||
212
tools/c2/templates/index.html
Normal file
212
tools/c2/templates/index.html
Normal file
@ -0,0 +1,212 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cameras - ESPILON</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #3fb950;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.logout {
|
||||
color: #8b949e;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.title span {
|
||||
color: #c9d1d9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card-header .name {
|
||||
font-family: monospace;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.card-header .badge {
|
||||
font-size: 11px;
|
||||
color: #3fb950;
|
||||
background: #3fb95026;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
background: #010409;
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-body img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.empty h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #c9d1d9;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">ESPILON</div>
|
||||
<div class="header-right">
|
||||
<div class="status">
|
||||
<div class="status-dot"></div>
|
||||
<span id="camera-count">{{ image_files|length }}</span> camera(s)
|
||||
</div>
|
||||
<a href="/logout" class="logout">Logout</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="toolbar">
|
||||
<div class="title">Cameras <span>Live Feed</span></div>
|
||||
</div>
|
||||
|
||||
{% if image_files %}
|
||||
<div class="grid" id="grid">
|
||||
{% for img in image_files %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="name">{{ img.replace('.jpg', '').replace('_', ':') }}</span>
|
||||
<span class="badge">LIVE</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<img src="/streams/{{ img }}?t=0" data-src="/streams/{{ img }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
<h2>No active cameras</h2>
|
||||
<p>Waiting for ESP devices to send frames on UDP port 5000</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function refresh() {
|
||||
const t = Date.now();
|
||||
document.querySelectorAll('.card-body img').forEach(img => {
|
||||
img.src = img.dataset.src + '?t=' + t;
|
||||
});
|
||||
}
|
||||
|
||||
async function checkCameras() {
|
||||
try {
|
||||
const res = await fetch('/api/cameras');
|
||||
const data = await res.json();
|
||||
const current = document.querySelectorAll('.card').length;
|
||||
document.getElementById('camera-count').textContent = data.cameras.length;
|
||||
if (data.cameras.length !== current) location.reload();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
setInterval(refresh, 100);
|
||||
setInterval(checkCameras, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
115
tools/c2/templates/login.html
Normal file
115
tools/c2/templates/login.html
Normal file
@ -0,0 +1,115 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - ESPILON</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0d1117;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-box {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 32px;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
color: #c9d1d9;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f8514926;
|
||||
border: 1px solid #f85149;
|
||||
color: #f85149;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
color: #c9d1d9;
|
||||
font-size: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #c9d1d9;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #58a6ff;
|
||||
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: #238636;
|
||||
border: 1px solid #238636;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2ea043;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-box">
|
||||
<div class="logo">ESPILON</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user