ε - C2 implementation camera

This commit is contained in:
Eun0us 2026-01-19 13:09:09 +01:00
parent a9151d4fd2
commit b931c81a13
10 changed files with 979 additions and 4 deletions

47
tools/c2/.env.example Normal file
View 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

View File

@ -93,7 +93,7 @@ def main():
$$$$$$$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\
$$$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$\ $$$$$$$$\ $$$$$$\ $$$$$$$\ $$$$$$\ $$\ $$$$$$\ $$\ $$\ $$$$$$\ $$$$$$\
$$ _____|$$ __$$\ $$ __$$\\_$$ _|$$ | $$ __$$\ $$$\ $$ | $$ __$$\ $$ __$$\ $$ _____|$$ __$$\ $$ __$$\ \_$$ _|$$ | $$ __$$\ $$$\ $$ | $$ __$$\ $$ __$$\
$$ | $$ / \__|$$ | $$ | $$ | $$ | $$ / $$ |$$$$\ $$ | $$ / \__|\__/ $$ | $$ | $$ / \__|$$ | $$ | $$ | $$ | $$ / $$ |$$$$\ $$ | $$ / \__|\__/ $$ |
$$$$$\ \$$$$$$\ $$$$$$$ | $$ | $$ | $$ | $$ |$$ $$\$$ | $$ | $$$$$$ | $$$$$\ \$$$$$$\ $$$$$$$ | $$ | $$ | $$ | $$ |$$ $$\$$ | $$ | $$$$$$ |
$$ __| \____$$\ $$ ____/ $$ | $$ | $$ | $$ |$$ \$$$$ | $$ | $$ ____/ $$ __| \____$$\ $$ ____/ $$ | $$ | $$ | $$ |$$ \$$$$ | $$ | $$ ____/

View File

@ -0,0 +1,3 @@
from .server import CameraServer
__all__ = ["CameraServer"]

62
tools/c2/camera/config.py Normal file
View 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
View 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

View 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)
}

View 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}"

View File

@ -1,11 +1,13 @@
import readline import readline
import os import os
import time import time
from typing import Optional
from utils.display import Display from utils.display import Display
from cli.help import HelpManager from cli.help import HelpManager
from core.transport import Transport from core.transport import Transport
from proto.c2_pb2 import Command from proto.c2_pb2 import Command
from camera import CameraServer
DEV_MODE = True DEV_MODE = True
@ -17,7 +19,10 @@ class CLI:
self.groups = groups self.groups = groups
self.transport = transport self.transport = transport
self.help_manager = HelpManager(commands, DEV_MODE) 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.parse_and_bind("tab: complete")
readline.set_completer(self._complete) readline.set_completer(self._complete)
@ -31,7 +36,7 @@ class CLI:
options = [] options = []
if len(parts) == 1: 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": elif parts[0] == "send":
if len(parts) == 2: # Completing target (device ID, 'all', 'group') if len(parts) == 2: # Completing target (device ID, 'all', 'group')
@ -42,6 +47,10 @@ class CLI:
options = self.commands.list() options = self.commands.list()
# Add more logic here if commands have arguments that can be tab-completed # 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": elif parts[0] == "group":
if len(parts) == 2: # Completing group action if len(parts) == 2: # Completing group action
options = ["add", "remove", "list", "show"] options = ["add", "remove", "list", "show"]
@ -98,6 +107,10 @@ class CLI:
self._handle_active_commands() self._handle_active_commands()
continue continue
if action == "camera":
self._handle_camera(parts[1:])
continue
Display.error("Unknown command") Display.error("Unknown command")
# ================= HANDLERS ================= # ================= HANDLERS =================
@ -287,3 +300,58 @@ class CLI:
cmd_info["status"], cmd_info["status"],
elapsed_time 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")

View 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>

View 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>