ε - Implentation module camera in c2 + multilateration on web front start with camera start

This commit is contained in:
Eun0us 2026-01-27 15:11:33 +01:00
parent b931c81a13
commit 3ee76bb605
26 changed files with 2400 additions and 370 deletions

View File

@ -1,13 +1,25 @@
#include "command.h"
#include "utils.h"
#include "esp_log.h"
#include <string.h>
#include <stdlib.h>
static const char *TAG = "COMMAND";
static const command_t *registry[MAX_COMMANDS];
static size_t registry_count = 0;
/* Max longueur lue/copied par arg (sécurité si non \0) */
#ifndef COMMAND_MAX_ARG_LEN
#define COMMAND_MAX_ARG_LEN 128
#endif
/* Max args temporaires quon accepte ici (doit couvrir tes commandes) */
#ifndef COMMAND_MAX_ARGS
#define COMMAND_MAX_ARGS 16
#endif
/* =========================================================
* Register command
* ========================================================= */
@ -28,15 +40,78 @@ void command_register(const command_t *cmd)
}
/* =========================================================
* Dispatch protobuf command
* Helpers: deep-copy argv into one arena + argv[] pointers
* ========================================================= */
static bool deepcopy_argv(char *const *argv_in,
int argc,
char ***argv_out,
char **arena_out,
const char *req_id)
{
*argv_out = NULL;
*arena_out = NULL;
if (argc < 0) {
msg_error("cmd", "Invalid argc", req_id);
return false;
}
if (argc == 0) {
char **argv0 = (char **)calloc(1, sizeof(char *));
if (!argv0) {
msg_error("cmd", "OOM copying argv", req_id);
return false;
}
*argv_out = argv0;
*arena_out = NULL;
return true;
}
size_t total = 0;
for (int i = 0; i < argc; i++) {
const char *s = (argv_in && argv_in[i]) ? argv_in[i] : "";
size_t n = strnlen(s, COMMAND_MAX_ARG_LEN);
total += (n + 1);
}
char *arena = (char *)malloc(total ? total : 1);
char **argv_copy = (char **)malloc((size_t)argc * sizeof(char *));
if (!arena || !argv_copy) {
free(arena);
free(argv_copy);
msg_error("cmd", "OOM copying argv", req_id);
return false;
}
size_t off = 0;
for (int i = 0; i < argc; i++) {
const char *s = (argv_in && argv_in[i]) ? argv_in[i] : "";
size_t n = strnlen(s, COMMAND_MAX_ARG_LEN);
argv_copy[i] = &arena[off];
memcpy(&arena[off], s, n);
arena[off + n] = '\0';
off += (n + 1);
}
*argv_out = argv_copy;
*arena_out = arena;
return true;
}
/* =========================================================
* Dispatch nanopb command
* ========================================================= */
void command_process_pb(const c2_Command *cmd)
{
if (!cmd) return;
const char *name = cmd->command_name;
/* nanopb: tableaux fixes => jamais NULL */
const char *name = cmd->command_name;
const char *reqid = cmd->request_id;
const char *reqid_or_null = (reqid[0] ? reqid : NULL);
int argc = cmd->argv_count;
char **argv = (char **)cmd->argv;
for (size_t i = 0; i < registry_count; i++) {
const command_t *c = registry[i];
@ -44,22 +119,48 @@ void command_process_pb(const c2_Command *cmd)
if (strcmp(c->name, name) != 0)
continue;
/* Validate argc */
if (argc < c->min_args || argc > c->max_args) {
msg_error("cmd", "Invalid argument count",
cmd->request_id);
msg_error("cmd", "Invalid argument count", reqid_or_null);
return;
}
ESP_LOGI(TAG, "Execute: %s (argc=%d)", name, argc);
if (c->async) {
/* Ton async copie déjà argv/request_id dans une queue => OK */
command_async_enqueue(c, cmd);
} else {
c->handler(argc, argv, cmd->request_id, c->ctx);
return;
}
/* ================================
* SYNC PATH (FIX):
* Ne PAS caster cmd->argv en char**
* On construit argv_ptrs[] depuis cmd->argv[i]
* ================================ */
if (argc > COMMAND_MAX_ARGS) {
msg_error("cmd", "Too many args", reqid_or_null);
return;
}
char *argv_ptrs[COMMAND_MAX_ARGS] = {0};
for (int a = 0; a < argc; a++) {
/* Fonctionne que cmd->argv soit char*[N] ou char[N][M] */
argv_ptrs[a] = (char *)cmd->argv[a];
}
/* Deep-copy pour rendre sync aussi safe que async */
char **argv_copy = NULL;
char *arena = NULL;
if (!deepcopy_argv(argv_ptrs, argc, &argv_copy, &arena, reqid_or_null))
return;
c->handler(argc, argv_copy, reqid_or_null, c->ctx);
free(argv_copy);
free(arena);
return;
}
msg_error("cmd", "Unknown command", cmd->request_id);
msg_error("cmd", "Unknown command", reqid_or_null);
}

View File

@ -13,7 +13,6 @@
#include "utils.h" /* CONFIG_*, base64, crypto */
#include "command.h" /* process_command */
#include "crypto.h" /* c2_decode_and_exec */
#ifdef CONFIG_NETWORK_GPRS

View File

@ -13,6 +13,8 @@
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <errno.h>
#include <ctype.h>
#include "command.h"
#include "utils.h"
@ -23,7 +25,7 @@
#define TAG "CAMERA"
#define MAX_UDP_SIZE 2034
#if defined(CONFIG_MODULE_RECON) && defined(CONFIG_RECON_MODE_CAMERA)
#if defined(CONFIG_RECON_MODE_CAMERA)
/* ================= CAMERA PINS ================= */
#define CAM_PIN_PWDN 32
#define CAM_PIN_RESET -1
@ -108,6 +110,8 @@ static void udp_stream_task(void *arg)
const size_t token_len = strlen(token);
uint8_t buf[MAX_UDP_SIZE + 32];
uint32_t frame_count = 0;
uint32_t error_count = 0;
while (streaming_active) {
@ -118,14 +122,34 @@ static void udp_stream_task(void *arg)
continue;
}
frame_count++;
size_t num_chunks = (fb->len + MAX_UDP_SIZE - 1) / MAX_UDP_SIZE;
/* DEBUG: Log frame info every 10 frames */
if (frame_count % 10 == 1) {
ESP_LOGI(TAG, "frame #%lu: %u bytes, %u chunks, sock=%d",
frame_count, fb->len, num_chunks, udp_sock);
}
/* Check socket validity */
if (udp_sock < 0) {
ESP_LOGE(TAG, "socket invalid (sock=%d), stopping", udp_sock);
esp_camera_fb_return(fb);
break;
}
/* START */
memcpy(buf, token, token_len);
memcpy(buf + token_len, "START", 5);
sendto(udp_sock, buf, token_len + 5, 0,
ssize_t ret = sendto(udp_sock, buf, token_len + 5, 0,
(struct sockaddr *)&dest_addr, sizeof(dest_addr));
if (ret < 0) {
ESP_LOGE(TAG, "START send failed: errno=%d (%s)", errno, strerror(errno));
}
size_t off = 0;
size_t rem = fb->len;
size_t chunk_num = 0;
while (rem > 0 && streaming_active) {
size_t chunk = rem > MAX_UDP_SIZE ? MAX_UDP_SIZE : rem;
@ -133,23 +157,39 @@ static void udp_stream_task(void *arg)
memcpy(buf, token, token_len);
memcpy(buf + token_len, fb->buf + off, chunk);
if (sendto(udp_sock, buf, token_len + chunk, 0,
ret = sendto(udp_sock, buf, token_len + chunk, 0,
(struct sockaddr *)&dest_addr,
sizeof(dest_addr)) < 0) {
msg_error(TAG, "udp send failed", NULL);
sizeof(dest_addr));
if (ret < 0) {
error_count++;
ESP_LOGE(TAG, "chunk %u/%u send failed: errno=%d (%s), errors=%lu",
chunk_num, num_chunks, errno, strerror(errno), error_count);
/* Stop after too many consecutive errors */
if (error_count > 50) {
ESP_LOGE(TAG, "too many errors, stopping stream");
streaming_active = false;
}
break;
} else {
error_count = 0; /* Reset on success */
}
off += chunk;
rem -= chunk;
chunk_num++;
vTaskDelay(1);
}
/* END */
memcpy(buf, token, token_len);
memcpy(buf + token_len, "END", 3);
sendto(udp_sock, buf, token_len + 3, 0,
ret = sendto(udp_sock, buf, token_len + 3, 0,
(struct sockaddr *)&dest_addr, sizeof(dest_addr));
if (ret < 0) {
ESP_LOGE(TAG, "END send failed: errno=%d (%s)", errno, strerror(errno));
}
esp_camera_fb_return(fb);
vTaskDelay(pdMS_TO_TICKS(140)); /* ~7 FPS */
@ -160,6 +200,7 @@ static void udp_stream_task(void *arg)
udp_sock = -1;
}
ESP_LOGI(TAG, "stream stopped after %lu frames", frame_count);
msg_info(TAG, "stream stopped", NULL);
vTaskDelete(NULL);
}
@ -169,31 +210,62 @@ static void udp_stream_task(void *arg)
* ============================================================ */
static void start_stream(const char *ip, uint16_t port)
{
ESP_LOGI(TAG, "start_stream called: ip=%s port=%u", ip ? ip : "(null)", port);
if (streaming_active) {
msg_error(TAG, "stream already active", NULL);
return;
}
if (!camera_initialized) {
if (!init_camera())
return;
camera_initialized = true;
}
udp_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
if (udp_sock < 0) {
msg_error(TAG, "udp socket failed", NULL);
if (!ip || ip[0] == '\0') {
ESP_LOGE(TAG, "invalid IP: null/empty");
msg_error(TAG, "invalid ip", NULL);
return;
}
if (port == 0) {
ESP_LOGE(TAG, "invalid port: 0");
msg_error(TAG, "invalid port", NULL);
return;
}
if (!camera_initialized) {
ESP_LOGI(TAG, "initializing camera...");
if (!init_camera()) {
msg_error(TAG, "camera init failed", NULL);
return;
}
camera_initialized = true;
}
// Create UDP socket
udp_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_IP);
if (udp_sock < 0) {
ESP_LOGE(TAG, "socket() failed: errno=%d (%s)", errno, strerror(errno));
msg_error(TAG, "udp socket failed", NULL);
return;
}
ESP_LOGI(TAG, "socket created: fd=%d", udp_sock);
// Build destination address (use inet_pton instead of inet_addr)
memset(&dest_addr, 0, sizeof(dest_addr));
dest_addr.sin_family = AF_INET;
dest_addr.sin_port = htons(port);
dest_addr.sin_addr.s_addr = inet_addr(ip);
dest_addr.sin_port = htons(port);
if (inet_pton(AF_INET, ip, &dest_addr.sin_addr) != 1) {
ESP_LOGE(TAG, "invalid IP address: '%s'", ip);
close(udp_sock);
udp_sock = -1;
msg_error(TAG, "invalid ip", NULL);
return;
}
ESP_LOGI(TAG, "target: %s:%u (addr=0x%08x)",
ip, port, (unsigned)dest_addr.sin_addr.s_addr);
streaming_active = true;
xTaskCreatePinnedToCore(
BaseType_t ret = xTaskCreatePinnedToCore(
udp_stream_task,
"cam_stream",
8192,
@ -202,25 +274,35 @@ static void start_stream(const char *ip, uint16_t port)
NULL,
0
);
if (ret != pdPASS) {
ESP_LOGE(TAG, "failed to create stream task");
streaming_active = false;
close(udp_sock);
udp_sock = -1;
msg_error(TAG, "task create failed", NULL);
return;
}
}
static void stop_stream(void)
{
ESP_LOGI(TAG, "stop_stream called, active=%d", streaming_active);
if (!streaming_active) {
msg_error(TAG, "no active stream", NULL);
return;
}
streaming_active = false;
ESP_LOGI(TAG, "stream stop requested");
}
/* ============================================================
* COMMAND HANDLERS
* ============================================================ */
static int cmd_cam_start(int argc,
char **argv,
const char *req,
void *ctx)
static int cmd_cam_start(int argc, char **argv, const char *req, void *ctx)
{
(void)ctx;
@ -229,10 +311,56 @@ static int cmd_cam_start(int argc,
return -1;
}
start_stream(argv[0], (uint16_t)atoi(argv[1]));
// Copie défensive (au cas où argv pointe vers un buffer volatile)
char ip[32] = {0};
char port_s[32] = {0};
strlcpy(ip, argv[0] ? argv[0] : "", sizeof(ip));
strlcpy(port_s, argv[1] ? argv[1] : "", sizeof(port_s));
// Trim espaces (début/fin) pour gérer "5000\r\n" etc.
char *p = port_s;
while (*p && isspace((unsigned char)*p)) p++;
// Extraire uniquement les digits au début
char digits[8] = {0}; // "65535" max
size_t di = 0;
while (*p && isdigit((unsigned char)*p) && di < sizeof(digits) - 1) {
digits[di++] = *p++;
}
digits[di] = '\0';
// Si aucun digit trouvé -> invalid
if (di == 0) {
ESP_LOGE(TAG, "invalid port (raw='%s')", port_s);
// Dump hex pour debug (hyper utile)
ESP_LOG_BUFFER_HEX(TAG, port_s, strnlen(port_s, sizeof(port_s)));
msg_error(TAG, "invalid port", req);
return -1;
}
unsigned long port_ul = strtoul(digits, NULL, 10);
if (port_ul == 0 || port_ul > 65535) {
ESP_LOGE(TAG, "invalid port value (digits='%s')", digits);
msg_error(TAG, "invalid port", req);
return -1;
}
uint16_t port = (uint16_t)port_ul;
// IP check via inet_pton (robuste)
struct in_addr addr;
if (inet_pton(AF_INET, ip, &addr) != 1) {
ESP_LOGE(TAG, "invalid IP address: '%s'", ip);
msg_error(TAG, "invalid ip", req);
return -1;
}
ESP_LOGI(TAG, "parsed: ip='%s' port=%u (raw_port='%s')", ip, port, port_s);
start_stream(ip, port);
return 0;
}
static int cmd_cam_stop(int argc,
char **argv,
const char *req,

View File

@ -12,6 +12,19 @@
#include "command.h"
#include "cmd_system.h"
/* Module headers */
#ifdef CONFIG_MODULE_NETWORK
#include "cmd_network.h"
#endif
#ifdef CONFIG_MODULE_FAKEAP
#include "cmd_fakeAP.h"
#endif
#ifdef CONFIG_MODULE_RECON
#include "cmd_recon.h"
#endif
static const char *TAG = "MAIN";
static void init_nvs(void)
@ -39,20 +52,25 @@ void app_main(void)
command_async_init(); // Async worker (Core 1)
mod_system_register_commands();
/* Register enabled modules */
#ifdef CONFIG_MODULE_NETWORK
#include "cmd_network.h"
mod_network_register_commands();
ESP_LOGI(TAG, "Network module loaded");
#endif
#elif defined(CONFIG_MODULE_FAKEAP)
#include "cmd_fakeAP.h"
#ifdef CONFIG_MODULE_FAKEAP
mod_fakeap_register_commands();
ESP_LOGI(TAG, "FakeAP module loaded");
#endif
#elif defined(CONFIG_MODULE_RECON)
#include "cmd_recon.h"
#ifdef CONFIG_MODULE_RECON
#ifdef CONFIG_RECON_MODE_CAMERA
mod_camera_register_commands();
#elif defined(CONFIG_RECON_MODE_BLE_TRILAT)
ESP_LOGI(TAG, "Camera module loaded");
#endif
#ifdef CONFIG_RECON_MODE_BLE_TRILAT
mod_ble_trilat_register_commands();
ESP_LOGI(TAG, "BLE Trilateration module loaded");
#endif
#endif

View File

@ -13,7 +13,7 @@ import sys
from core.registry import DeviceRegistry
from core.transport import Transport
from logs.manager import LogManager
from log.manager import LogManager
from cli.cli import CLI
from commands.registry import CommandRegistry
from commands.reboot import RebootCommand
@ -25,7 +25,7 @@ from utils.display import Display # Import Display utility
BASE64_RE = re.compile(br'^[A-Za-z0-9+/=]+$')
RX_BUF_SIZE = 4096
DEVICE_TIMEOUT_SECONDS = 60 # Devices are considered inactive after 60 seconds without a heartbeat
DEVICE_TIMEOUT_SECONDS = 300 # Devices are considered inactive after 5 minutes without a heartbeat
HEARTBEAT_CHECK_INTERVAL = 10 # Check every 10 seconds

View File

@ -60,3 +60,6 @@ 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")
# Multilateration
MULTILAT_AUTH_TOKEN = os.getenv("MULTILAT_AUTH_TOKEN", "multilat_secret_token")

View File

@ -1,17 +1,21 @@
"""Main camera server combining UDP receiver and web server."""
"""Main camera server combining UDP receiver and unified 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 .config import (
UDP_HOST, UDP_PORT, WEB_HOST, WEB_PORT, IMAGE_DIR,
DEFAULT_USERNAME, DEFAULT_PASSWORD, FLASK_SECRET_KEY, MULTILAT_AUTH_TOKEN
)
from .udp_receiver import UDPReceiver
from .web_server import WebServer
from web.server import UnifiedWebServer
from web.multilateration import MultilaterationEngine
class CameraServer:
"""
Combined camera server that manages both:
- UDP receiver for incoming camera frames from ESP devices
- Web server for viewing the camera streams
- Unified web server for dashboard, cameras, and trilateration
"""
def __init__(self,
@ -22,6 +26,7 @@ class CameraServer:
image_dir: str = IMAGE_DIR,
username: str = DEFAULT_USERNAME,
password: str = DEFAULT_PASSWORD,
device_registry=None,
on_frame: Optional[Callable] = None):
"""
Initialize the camera server.
@ -34,8 +39,11 @@ class CameraServer:
image_dir: Directory to store camera frames
username: Web interface username
password: Web interface password
device_registry: DeviceRegistry instance for device listing
on_frame: Optional callback when frame is received (camera_id, frame, addr)
"""
self.multilat_engine = MultilaterationEngine()
self.udp_receiver = UDPReceiver(
host=udp_host,
port=udp_port,
@ -43,12 +51,16 @@ class CameraServer:
on_frame=on_frame
)
self.web_server = WebServer(
self.web_server = UnifiedWebServer(
host=web_host,
port=web_port,
image_dir=image_dir,
username=username,
password=password
password=password,
secret_key=FLASK_SECRET_KEY,
multilat_token=MULTILAT_AUTH_TOKEN,
device_registry=device_registry,
multilateration_engine=self.multilat_engine
)
@property

View File

@ -1,11 +1,18 @@
"""UDP server for receiving camera frames from ESP devices."""
"""UDP server for receiving camera frames from ESP devices.
Protocol from ESP32:
- TOKEN + "START" -> Start of new frame
- TOKEN + chunk -> JPEG data chunk
- TOKEN + "END" -> End of frame, decode and process
"""
import os
import socket
import threading
import time
import cv2
import numpy as np
from typing import Optional, Callable
from typing import Optional, Callable, Dict
from .config import (
UDP_HOST, UDP_PORT, UDP_BUFFER_SIZE,
@ -14,6 +21,46 @@ from .config import (
)
class FrameAssembler:
"""Assembles JPEG frames from multiple UDP packets."""
def __init__(self, timeout: float = 5.0):
self.timeout = timeout
self.buffer = bytearray()
self.start_time: Optional[float] = None
self.receiving = False
def start_frame(self):
"""Start receiving a new frame."""
self.buffer = bytearray()
self.start_time = time.time()
self.receiving = True
def add_chunk(self, data: bytes) -> bool:
"""Add a chunk to the frame buffer. Returns False if timed out."""
if not self.receiving:
return False
if self.start_time and (time.time() - self.start_time) > self.timeout:
self.reset()
return False
self.buffer.extend(data)
return True
def finish_frame(self) -> Optional[bytes]:
"""Finish frame assembly and return complete data."""
if not self.receiving or len(self.buffer) == 0:
return None
data = bytes(self.buffer)
self.reset()
return data
def reset(self):
"""Reset the assembler state."""
self.buffer = bytearray()
self.start_time = None
self.receiving = False
class UDPReceiver:
"""Receives JPEG frames via UDP from ESP camera devices."""
@ -31,6 +78,9 @@ class UDPReceiver:
self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
# Frame assemblers per source address
self._assemblers: Dict[str, FrameAssembler] = {}
# Video recording
self._video_writer: Optional[cv2.VideoWriter] = None
self._video_size: Optional[tuple] = None
@ -39,6 +89,7 @@ class UDPReceiver:
self.frames_received = 0
self.invalid_tokens = 0
self.decode_errors = 0
self.packets_received = 0
# Active cameras tracking
self._active_cameras: dict = {} # {camera_id: last_frame_time}
@ -83,7 +134,9 @@ class UDPReceiver:
self._cleanup_frames()
self._active_cameras.clear()
self._assemblers.clear()
self.frames_received = 0
self.packets_received = 0
def _cleanup_frames(self):
"""Remove all .jpg files from image directory."""
@ -94,13 +147,22 @@ class UDPReceiver:
except Exception:
pass
def _get_assembler(self, addr: tuple) -> FrameAssembler:
"""Get or create a frame assembler for the given address."""
key = f"{addr[0]}:{addr[1]}"
if key not in self._assemblers:
self._assemblers[key] = FrameAssembler()
return self._assemblers[key]
def _receive_loop(self):
"""Main UDP receive loop."""
"""Main UDP receive loop with START/END protocol handling."""
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)
print(f"[UDP] Receiver started on {self.host}:{self.port}")
while not self._stop_event.is_set():
try:
data, addr = self._sock.recvfrom(UDP_BUFFER_SIZE)
@ -109,34 +171,38 @@ class UDPReceiver:
except OSError:
break
self.packets_received += 1
# 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
payload = data[len(SECRET_TOKEN):]
assembler = self._get_assembler(addr)
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)
# Handle protocol markers
if payload == b"START":
assembler.start_frame()
continue
elif payload == b"END":
frame_data = assembler.finish_frame()
if frame_data:
self._process_complete_frame(camera_id, frame_data, addr)
continue
else:
# Regular data chunk
if not assembler.receiving:
# No START received, try as single-packet frame (legacy)
frame = self._decode_frame(payload)
if frame is not None:
self._process_frame(camera_id, frame, addr)
else:
self.decode_errors += 1
else:
assembler.add_chunk(payload)
# Cleanup
if self._sock:
@ -147,6 +213,32 @@ class UDPReceiver:
self._video_writer.release()
self._video_writer = None
print("[UDP] Receiver stopped")
def _process_complete_frame(self, camera_id: str, frame_data: bytes, addr: tuple):
"""Process a fully assembled frame."""
frame = self._decode_frame(frame_data)
if frame is None:
self.decode_errors += 1
return
self._process_frame(camera_id, frame, addr)
def _process_frame(self, camera_id: str, frame: np.ndarray, addr: tuple):
"""Process a decoded frame."""
self.frames_received += 1
self._active_cameras[camera_id] = time.time()
# 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)
def _decode_frame(self, data: bytes) -> Optional[np.ndarray]:
"""Decode JPEG data to OpenCV frame."""
try:
@ -181,6 +273,7 @@ class UDPReceiver:
"""Return receiver statistics."""
return {
"running": self.is_running,
"packets_received": self.packets_received,
"frames_received": self.frames_received,
"invalid_tokens": self.invalid_tokens,
"decode_errors": self.decode_errors,

View File

@ -313,7 +313,7 @@ class CLI:
Display.system_message("Camera server is already running.")
return
self.camera_server = CameraServer()
self.camera_server = CameraServer(device_registry=self.registry)
result = self.camera_server.start()
if result["udp"]["started"]:

View File

@ -1,7 +1,7 @@
from core.crypto import CryptoContext
from core.device import Device
from core.registry import DeviceRegistry
from logs.manager import LogManager
from log.manager import LogManager
from utils.display import Display
from proto.c2_pb2 import Command, AgentMessage, AgentMsgType

3
tools/c2/log/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .manager import LogManager
__all__ = ["LogManager"]

66
tools/c2/log/manager.py Normal file
View File

@ -0,0 +1,66 @@
"""Log manager for storing device messages."""
import time
from typing import Dict, List, Optional
from dataclasses import dataclass
@dataclass
class LogEntry:
"""A single log entry from a device."""
timestamp: float
device_id: str
msg_type: str
source: str
payload: str
request_id: Optional[str] = None
class LogManager:
"""Manages log storage for device messages."""
def __init__(self, max_entries_per_device: int = 1000):
self.max_entries = max_entries_per_device
self._logs: Dict[str, List[LogEntry]] = {}
def add(self, device_id: str, msg_type: str, source: str, payload: str, request_id: str = None):
if device_id not in self._logs:
self._logs[device_id] = []
entry = LogEntry(
timestamp=time.time(),
device_id=device_id,
msg_type=msg_type,
source=source,
payload=payload,
request_id=request_id
)
self._logs[device_id].append(entry)
if len(self._logs[device_id]) > self.max_entries:
self._logs[device_id] = self._logs[device_id][-self.max_entries:]
def get_logs(self, device_id: str, limit: int = 100) -> List[LogEntry]:
if device_id not in self._logs:
return []
return self._logs[device_id][-limit:]
def get_all_logs(self, limit: int = 100) -> List[LogEntry]:
all_entries = []
for entries in self._logs.values():
all_entries.extend(entries)
all_entries.sort(key=lambda e: e.timestamp)
return all_entries[-limit:]
def clear(self, device_id: str = None):
if device_id:
self._logs.pop(device_id, None)
else:
self._logs.clear()
def device_count(self) -> int:
return len(self._logs)
def total_entries(self) -> int:
return sum(len(entries) for entries in self._logs.values())

View File

@ -0,0 +1,639 @@
/* ESPILON C2 - Violet Theme */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
/* Background colors - deep dark with violet undertones */
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #06060a;
--bg-elevated: #1a1a25;
/* Border colors */
--border-color: #2a2a3d;
--border-light: #3d3d55;
/* Text colors */
--text-primary: #e4e4ed;
--text-secondary: #8888a0;
--text-muted: #5a5a70;
/* Accent colors - violet palette */
--accent-primary: #a855f7;
--accent-primary-hover: #c084fc;
--accent-primary-bg: rgba(168, 85, 247, 0.15);
--accent-primary-glow: rgba(168, 85, 247, 0.4);
--accent-secondary: #818cf8;
--accent-secondary-bg: rgba(129, 140, 248, 0.15);
/* Status colors */
--status-online: #22d3ee;
--status-online-bg: rgba(34, 211, 238, 0.15);
--status-warning: #fbbf24;
--status-warning-bg: rgba(251, 191, 36, 0.15);
--status-error: #f87171;
--status-error-bg: rgba(248, 113, 113, 0.15);
--status-success: #4ade80;
--status-success-bg: rgba(74, 222, 128, 0.15);
/* Button colors */
--btn-primary: #7c3aed;
--btn-primary-hover: #8b5cf6;
--btn-secondary: #1e1e2e;
--btn-secondary-hover: #2a2a3d;
/* Gradients */
--gradient-primary: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%);
--gradient-glow: radial-gradient(circle at center, var(--accent-primary-glow) 0%, transparent 70%);
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
/* ========== Header ========== */
header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding: 12px 24px;
display: flex;
justify-content: space-between;
align-items: center;
backdrop-filter: blur(10px);
}
.logo {
font-size: 18px;
font-weight: 700;
letter-spacing: 2px;
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.main-nav {
display: flex;
gap: 4px;
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
font-weight: 500;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.2s ease;
position: relative;
}
.nav-link:hover {
color: var(--text-primary);
background: var(--bg-elevated);
}
.nav-link.active {
color: var(--accent-primary);
background: var(--accent-primary-bg);
}
.nav-link.active::after {
content: '';
position: absolute;
bottom: -13px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 2px;
background: var(--accent-primary);
border-radius: 2px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
padding: 6px 12px;
background: var(--bg-elevated);
border-radius: 20px;
}
.status-dot {
width: 8px;
height: 8px;
background: var(--status-online);
border-radius: 50%;
box-shadow: 0 0 8px var(--status-online);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.logout {
color: var(--text-secondary);
text-decoration: none;
font-size: 13px;
padding: 6px 12px;
border-radius: 6px;
transition: all 0.2s ease;
}
.logout:hover {
color: var(--text-primary);
background: var(--bg-elevated);
}
/* ========== Main Content ========== */
main {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.page-title {
font-size: 13px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
}
.page-title span {
color: var(--text-primary);
font-weight: 600;
margin-left: 8px;
}
/* ========== Cards Grid ========== */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.grid-cameras {
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
}
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
transition: all 0.2s ease;
}
.card:hover {
border-color: var(--border-light);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.card-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.card-header .name {
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
color: var(--text-primary);
}
.card-header .badge {
font-size: 10px;
font-weight: 600;
padding: 4px 10px;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-live {
color: var(--status-online);
background: var(--status-online-bg);
}
.badge-connected {
color: var(--status-success);
background: var(--status-success-bg);
}
.badge-inactive {
color: var(--status-warning);
background: var(--status-warning-bg);
}
.card-body {
padding: 16px;
}
.card-body-image {
background: var(--bg-tertiary);
min-height: 240px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.card-body-image img {
width: 100%;
height: auto;
display: block;
}
/* ========== Device Card ========== */
.device-info {
display: flex;
flex-direction: column;
gap: 10px;
}
.device-row {
display: flex;
justify-content: space-between;
font-size: 13px;
}
.device-row .label {
color: var(--text-muted);
}
.device-row .value {
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
/* ========== Empty State ========== */
.empty {
text-align: center;
padding: 80px 20px;
color: var(--text-secondary);
}
.empty h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.empty p {
font-size: 14px;
color: var(--text-muted);
}
/* ========== Multilateration Canvas ========== */
.trilat-container {
display: grid;
grid-template-columns: 1fr 320px;
gap: 20px;
}
@media (max-width: 900px) {
.trilat-container {
grid-template-columns: 1fr;
}
}
.trilat-canvas-wrapper {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
position: relative;
overflow: hidden;
}
.trilat-canvas-wrapper::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: var(--gradient-primary);
opacity: 0.5;
}
#trilat-canvas {
width: 100%;
height: 500px;
background: var(--bg-tertiary);
border-radius: 8px;
}
.trilat-sidebar {
display: flex;
flex-direction: column;
gap: 16px;
}
.trilat-panel {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
}
.trilat-panel h3 {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 14px;
color: var(--text-secondary);
}
.trilat-stat {
display: flex;
justify-content: space-between;
font-size: 13px;
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
}
.trilat-stat:last-child {
border-bottom: none;
}
.trilat-stat .label {
color: var(--text-muted);
}
.trilat-stat .value {
color: var(--accent-primary);
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
}
.scanner-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 200px;
overflow-y: auto;
}
.scanner-item {
background: var(--bg-elevated);
padding: 10px 12px;
border-radius: 8px;
font-size: 12px;
border: 1px solid transparent;
transition: all 0.2s ease;
}
.scanner-item:hover {
border-color: var(--border-light);
}
.scanner-item .scanner-id {
font-weight: 600;
color: var(--accent-secondary);
}
.scanner-item .scanner-details {
color: var(--text-muted);
margin-top: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
/* ========== Config Panel ========== */
.config-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
}
.config-row:last-child {
border-bottom: none;
}
.config-row label {
font-size: 13px;
color: var(--text-secondary);
}
.config-row input {
width: 80px;
padding: 6px 10px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
text-align: right;
transition: all 0.2s ease;
}
.config-row input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-primary-bg);
}
/* ========== Buttons ========== */
.btn {
padding: 10px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
.btn-primary {
background: var(--gradient-primary);
color: #fff;
box-shadow: 0 2px 10px var(--accent-primary-bg);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 15px var(--accent-primary-glow);
}
.btn-secondary {
background: var(--btn-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--btn-secondary-hover);
border-color: var(--border-light);
}
/* ========== Login Page ========== */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
position: relative;
}
.login-container::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 600px;
height: 600px;
background: var(--gradient-glow);
pointer-events: none;
}
.login-box {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 40px;
width: 100%;
max-width: 380px;
position: relative;
backdrop-filter: blur(10px);
}
.login-box .logo {
text-align: center;
margin-bottom: 32px;
font-size: 24px;
}
.error {
background: var(--status-error-bg);
border: 1px solid var(--status-error);
color: var(--status-error);
padding: 12px 14px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 13px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
margin-bottom: 8px;
color: var(--text-secondary);
}
.form-group input {
width: 100%;
padding: 12px 14px;
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
transition: all 0.2s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px var(--accent-primary-bg);
}
.form-group input::placeholder {
color: var(--text-muted);
}
.btn-login {
width: 100%;
padding: 14px 20px;
background: var(--gradient-primary);
border: none;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 10px var(--accent-primary-bg);
}
.btn-login:hover {
transform: translateY(-1px);
box-shadow: 0 4px 20px var(--accent-primary-glow);
}
/* ========== Scrollbar ========== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-light);
}
/* ========== Selection ========== */
::selection {
background: var(--accent-primary-bg);
color: var(--accent-primary);
}

View File

@ -0,0 +1,331 @@
/**
* Trilateration visualization for ESPILON C2
* Renders scanner positions and target location on a 2D canvas
*/
class TrilaterationViz {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
// Coordinate system bounds (auto-adjusted based on data)
this.bounds = { minX: -2, maxX: 15, minY: -2, maxY: 15 };
this.padding = 40;
// Data
this.scanners = [];
this.target = null;
// Colors
this.colors = {
background: '#010409',
grid: '#21262d',
gridText: '#484f58',
scanner: '#58a6ff',
scannerCircle: 'rgba(88, 166, 255, 0.15)',
target: '#f85149',
targetGlow: 'rgba(248, 81, 73, 0.3)',
text: '#c9d1d9'
};
this.resize();
window.addEventListener('resize', () => this.resize());
}
resize() {
const rect = this.canvas.parentElement.getBoundingClientRect();
this.canvas.width = rect.width - 32; // Account for padding
this.canvas.height = 500;
this.draw();
}
// Convert world coordinates to canvas coordinates
worldToCanvas(x, y) {
const w = this.canvas.width - this.padding * 2;
const h = this.canvas.height - this.padding * 2;
const rangeX = this.bounds.maxX - this.bounds.minX;
const rangeY = this.bounds.maxY - this.bounds.minY;
return {
x: this.padding + ((x - this.bounds.minX) / rangeX) * w,
y: this.canvas.height - this.padding - ((y - this.bounds.minY) / rangeY) * h
};
}
// Convert distance to canvas pixels
distanceToPixels(distance) {
const w = this.canvas.width - this.padding * 2;
const rangeX = this.bounds.maxX - this.bounds.minX;
return (distance / rangeX) * w;
}
updateBounds() {
if (this.scanners.length === 0) {
this.bounds = { minX: -2, maxX: 15, minY: -2, maxY: 15 };
return;
}
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
for (const s of this.scanners) {
minX = Math.min(minX, s.position.x);
maxX = Math.max(maxX, s.position.x);
minY = Math.min(minY, s.position.y);
maxY = Math.max(maxY, s.position.y);
}
if (this.target) {
minX = Math.min(minX, this.target.x);
maxX = Math.max(maxX, this.target.x);
minY = Math.min(minY, this.target.y);
maxY = Math.max(maxY, this.target.y);
}
// Add margin
const marginX = Math.max(2, (maxX - minX) * 0.2);
const marginY = Math.max(2, (maxY - minY) * 0.2);
this.bounds = {
minX: minX - marginX,
maxX: maxX + marginX,
minY: minY - marginY,
maxY: maxY + marginY
};
}
draw() {
const ctx = this.ctx;
const w = this.canvas.width;
const h = this.canvas.height;
// Clear
ctx.fillStyle = this.colors.background;
ctx.fillRect(0, 0, w, h);
// Draw grid
this.drawGrid();
// Draw scanner range circles
for (const scanner of this.scanners) {
if (scanner.estimated_distance) {
this.drawRangeCircle(scanner);
}
}
// Draw scanners
for (const scanner of this.scanners) {
this.drawScanner(scanner);
}
// Draw target
if (this.target) {
this.drawTarget();
}
}
drawGrid() {
const ctx = this.ctx;
ctx.strokeStyle = this.colors.grid;
ctx.lineWidth = 1;
ctx.font = '10px monospace';
ctx.fillStyle = this.colors.gridText;
// Determine grid spacing
const rangeX = this.bounds.maxX - this.bounds.minX;
const rangeY = this.bounds.maxY - this.bounds.minY;
const gridStep = Math.pow(10, Math.floor(Math.log10(Math.max(rangeX, rangeY) / 5)));
// Vertical lines
for (let x = Math.ceil(this.bounds.minX / gridStep) * gridStep; x <= this.bounds.maxX; x += gridStep) {
const p = this.worldToCanvas(x, 0);
ctx.beginPath();
ctx.moveTo(p.x, this.padding);
ctx.lineTo(p.x, this.canvas.height - this.padding);
ctx.stroke();
ctx.fillText(x.toFixed(1), p.x - 10, this.canvas.height - this.padding + 15);
}
// Horizontal lines
for (let y = Math.ceil(this.bounds.minY / gridStep) * gridStep; y <= this.bounds.maxY; y += gridStep) {
const p = this.worldToCanvas(0, y);
ctx.beginPath();
ctx.moveTo(this.padding, p.y);
ctx.lineTo(this.canvas.width - this.padding, p.y);
ctx.stroke();
ctx.fillText(y.toFixed(1), 5, p.y + 4);
}
}
drawRangeCircle(scanner) {
const ctx = this.ctx;
const pos = this.worldToCanvas(scanner.position.x, scanner.position.y);
const radius = this.distanceToPixels(scanner.estimated_distance);
ctx.beginPath();
ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
ctx.strokeStyle = this.colors.scannerCircle;
ctx.lineWidth = 2;
ctx.stroke();
}
drawScanner(scanner) {
const ctx = this.ctx;
const pos = this.worldToCanvas(scanner.position.x, scanner.position.y);
// Scanner dot
ctx.beginPath();
ctx.arc(pos.x, pos.y, 8, 0, Math.PI * 2);
ctx.fillStyle = this.colors.scanner;
ctx.fill();
// Label
ctx.font = '12px monospace';
ctx.fillStyle = this.colors.text;
ctx.textAlign = 'center';
ctx.fillText(scanner.id, pos.x, pos.y - 15);
// RSSI info
if (scanner.last_rssi !== null) {
ctx.font = '10px monospace';
ctx.fillStyle = this.colors.gridText;
ctx.fillText(`${scanner.last_rssi} dBm`, pos.x, pos.y + 20);
}
ctx.textAlign = 'left';
}
drawTarget() {
const ctx = this.ctx;
const pos = this.worldToCanvas(this.target.x, this.target.y);
// Glow effect
ctx.beginPath();
ctx.arc(pos.x, pos.y, 20, 0, Math.PI * 2);
ctx.fillStyle = this.colors.targetGlow;
ctx.fill();
// Cross marker
ctx.strokeStyle = this.colors.target;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(pos.x - 12, pos.y - 12);
ctx.lineTo(pos.x + 12, pos.y + 12);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(pos.x + 12, pos.y - 12);
ctx.lineTo(pos.x - 12, pos.y + 12);
ctx.stroke();
// Label
ctx.font = 'bold 12px monospace';
ctx.fillStyle = this.colors.target;
ctx.textAlign = 'center';
ctx.fillText('TARGET', pos.x, pos.y - 25);
ctx.textAlign = 'left';
}
update(state) {
this.scanners = state.scanners || [];
this.target = state.target?.position || null;
this.updateBounds();
this.draw();
}
}
// Initialize visualization
const viz = new TrilaterationViz('trilat-canvas');
// UI Update functions
function updateTargetInfo(target) {
if (target && target.position) {
document.getElementById('target-x').textContent = target.position.x.toFixed(2) + ' m';
document.getElementById('target-y').textContent = target.position.y.toFixed(2) + ' m';
document.getElementById('target-confidence').textContent = ((target.confidence || 0) * 100).toFixed(0) + '%';
document.getElementById('target-age').textContent = (target.age_seconds || 0).toFixed(1) + 's ago';
} else {
document.getElementById('target-x').textContent = '-';
document.getElementById('target-y').textContent = '-';
document.getElementById('target-confidence').textContent = '-';
document.getElementById('target-age').textContent = '-';
}
}
function updateScannerList(scanners) {
const list = document.getElementById('scanner-list');
document.getElementById('scanner-count').textContent = scanners.length;
if (scanners.length === 0) {
list.innerHTML = '<div class="empty" style="padding: 20px;"><p>No scanners active</p></div>';
return;
}
list.innerHTML = scanners.map(s => `
<div class="scanner-item">
<div class="scanner-id">${s.id}</div>
<div class="scanner-details">
Pos: (${s.position.x}, ${s.position.y}) |
RSSI: ${s.last_rssi !== null ? s.last_rssi + ' dBm' : '-'} |
Dist: ${s.estimated_distance !== null ? s.estimated_distance + 'm' : '-'}
</div>
</div>
`).join('');
}
function updateConfig(config) {
document.getElementById('config-rssi').value = config.rssi_at_1m;
document.getElementById('config-n').value = config.path_loss_n;
document.getElementById('config-smooth').value = config.smoothing_window;
}
// API functions
async function fetchState() {
try {
const res = await fetch('/api/multilat/state');
const state = await res.json();
viz.update(state);
updateTargetInfo(state.target);
updateScannerList(state.scanners);
if (state.config) {
updateConfig(state.config);
}
} catch (e) {
console.error('Failed to fetch trilateration state:', e);
}
}
async function saveConfig() {
const config = {
rssi_at_1m: parseFloat(document.getElementById('config-rssi').value),
path_loss_n: parseFloat(document.getElementById('config-n').value),
smoothing_window: parseInt(document.getElementById('config-smooth').value)
};
try {
await fetch('/api/multilat/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
console.log('Config saved');
} catch (e) {
console.error('Failed to save config:', e);
}
}
async function clearData() {
try {
await fetch('/api/multilat/clear', { method: 'POST' });
fetchState();
} catch (e) {
console.error('Failed to clear data:', e);
}
}
// Start polling
fetchState();
setInterval(fetchState, 2000);

BIN
tools/c2/static/record.avi Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ESPILON{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<header>
<div class="logo">ESPILON</div>
<nav class="main-nav">
<a href="/dashboard" class="nav-link {% if active_page == 'dashboard' %}active{% endif %}">
Dashboard
</a>
<a href="/cameras" class="nav-link {% if active_page == 'cameras' %}active{% endif %}">
Cameras
</a>
<a href="/multilateration" class="nav-link {% if active_page == 'multilateration' %}active{% endif %}">
Multilateration
</a>
</nav>
<div class="header-right">
<div class="status">
<div class="status-dot"></div>
<span id="device-count">-</span> device(s)
</div>
<a href="/logout" class="logout">Logout</a>
</div>
</header>
<main>
{% block content %}{% endblock %}
</main>
<script>
// Update device count in header
async function updateStats() {
try {
const res = await fetch('/api/stats');
const data = await res.json();
document.getElementById('device-count').textContent = data.connected_devices || 0;
} catch (e) {}
}
updateStats();
setInterval(updateStats, 10000);
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Cameras - ESPILON{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-title">Cameras <span>Live Feed</span></div>
<div class="status">
<div class="status-dot"></div>
<span id="camera-count">{{ image_files|length }}</span> camera(s)
</div>
</div>
{% if image_files %}
<div class="grid grid-cameras" 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 badge-live">LIVE</span>
</div>
<div class="card-body card-body-image">
<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 ESP32-CAM devices to send frames on UDP port 5000</p>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function refresh() {
const t = Date.now();
document.querySelectorAll('.card-body-image 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.count || 0;
if (data.count !== current) location.reload();
} catch (e) {}
}
setInterval(refresh, 100);
setInterval(checkCameras, 5000);
</script>
{% endblock %}

View File

@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Dashboard - ESPILON{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-title">Dashboard <span>Connected Devices</span></div>
</div>
<div id="devices-grid" class="grid">
<!-- Devices loaded via JavaScript -->
</div>
<div id="empty-state" class="empty" style="display: none;">
<h2>No devices connected</h2>
<p>Waiting for ESP32 agents to connect to the C2 server</p>
</div>
{% endblock %}
{% block scripts %}
<script>
function formatDuration(seconds) {
if (seconds < 60) return Math.round(seconds) + 's';
if (seconds < 3600) return Math.round(seconds / 60) + 'm';
const hours = Math.floor(seconds / 3600);
const mins = Math.round((seconds % 3600) / 60);
return hours + 'h ' + mins + 'm';
}
function createDeviceCard(device) {
const statusClass = device.status === 'Connected' ? 'badge-connected' : 'badge-inactive';
return `
<div class="card" data-device-id="${device.id}">
<div class="card-header">
<span class="name">${device.id}</span>
<span class="badge ${statusClass}">${device.status}</span>
</div>
<div class="card-body">
<div class="device-info">
<div class="device-row">
<span class="label">IP Address</span>
<span class="value">${device.ip}:${device.port}</span>
</div>
<div class="device-row">
<span class="label">Connected</span>
<span class="value">${formatDuration(device.connected_for_seconds)}</span>
</div>
<div class="device-row">
<span class="label">Last Seen</span>
<span class="value">${formatDuration(device.last_seen_ago_seconds)} ago</span>
</div>
</div>
</div>
</div>
`;
}
async function loadDevices() {
try {
const res = await fetch('/api/devices');
const data = await res.json();
const grid = document.getElementById('devices-grid');
const empty = document.getElementById('empty-state');
if (data.devices && data.devices.length > 0) {
grid.innerHTML = data.devices.map(createDeviceCard).join('');
grid.style.display = 'grid';
empty.style.display = 'none';
} else {
grid.style.display = 'none';
empty.style.display = 'block';
}
} catch (e) {
console.error('Failed to load devices:', e);
}
}
loadDevices();
setInterval(loadDevices, 5000);
</script>
{% endblock %}

View File

@ -1,212 +0,0 @@
<!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

@ -4,92 +4,9 @@
<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>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
</head>
<body>
<body class="login-container">
<div class="login-box">
<div class="logo">ESPILON</div>
@ -108,7 +25,7 @@
<input type="password" id="password" name="password" required>
</div>
<button type="submit" class="btn">Sign in</button>
<button type="submit" class="btn-login">Sign in</button>
</form>
</div>
</body>

View File

@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}Multilateration - ESPILON{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-title">Multilateration <span>BLE Positioning</span></div>
</div>
<div class="trilat-container">
<div class="trilat-canvas-wrapper">
<canvas id="trilat-canvas"></canvas>
</div>
<div class="trilat-sidebar">
<!-- Target Position -->
<div class="trilat-panel">
<h3>Target Position</h3>
<div class="trilat-stat">
<span class="label">X</span>
<span class="value" id="target-x">-</span>
</div>
<div class="trilat-stat">
<span class="label">Y</span>
<span class="value" id="target-y">-</span>
</div>
<div class="trilat-stat">
<span class="label">Confidence</span>
<span class="value" id="target-confidence">-</span>
</div>
<div class="trilat-stat">
<span class="label">Last Update</span>
<span class="value" id="target-age">-</span>
</div>
</div>
<!-- Active Scanners -->
<div class="trilat-panel">
<h3>Active Scanners (<span id="scanner-count">0</span>)</h3>
<div class="scanner-list" id="scanner-list">
<div class="empty" style="padding: 20px;">
<p>No scanners active</p>
</div>
</div>
</div>
<!-- Configuration -->
<div class="trilat-panel">
<h3>Configuration</h3>
<div class="config-row">
<label>RSSI at 1m</label>
<input type="number" id="config-rssi" value="-40" step="1">
</div>
<div class="config-row">
<label>Path Loss (n)</label>
<input type="number" id="config-n" value="2.5" step="0.1">
</div>
<div class="config-row">
<label>Smoothing</label>
<input type="number" id="config-smooth" value="5" min="1" max="20">
</div>
<div style="margin-top: 12px; display: flex; gap: 8px;">
<button class="btn btn-primary" onclick="saveConfig()">Save</button>
<button class="btn btn-secondary" onclick="clearData()">Clear</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/multilateration.js') }}"></script>
{% endblock %}

54
tools/c2/test_udp.py Normal file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Simple UDP test server to debug camera streaming."""
import socket
import sys
HOST = "0.0.0.0"
PORT = 5000
TOKEN = b"Sup3rS3cretT0k3n"
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else PORT
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((HOST, port))
print(f"[UDP] Listening on {HOST}:{port}")
print(f"[UDP] Token: {TOKEN.decode()}")
print("[UDP] Waiting for packets...\n")
packet_count = 0
frame_count = 0
try:
while True:
data, addr = sock.recvfrom(65535)
packet_count += 1
# Check token
if data.startswith(TOKEN):
payload = data[len(TOKEN):]
if payload == b"START":
print(f"[{addr[0]}:{addr[1]}] START (new frame)")
elif payload == b"END":
frame_count += 1
print(f"[{addr[0]}:{addr[1]}] END (frame #{frame_count} complete)")
else:
print(f"[{addr[0]}:{addr[1]}] CHUNK: {len(payload)} bytes")
else:
print(f"[{addr[0]}:{addr[1]}] INVALID TOKEN: {data[:20]}...")
# Stats every 100 packets
if packet_count % 100 == 0:
print(f"\n--- Stats: {packet_count} packets, {frame_count} frames ---\n")
except KeyboardInterrupt:
print(f"\n[UDP] Stopped. Total: {packet_count} packets, {frame_count} frames")
finally:
sock.close()
if __name__ == "__main__":
main()

6
tools/c2/web/__init__.py Normal file
View File

@ -0,0 +1,6 @@
"""Unified web server module for ESPILON C2."""
from .server import UnifiedWebServer
from .multilateration import MultilaterationEngine
__all__ = ["UnifiedWebServer", "MultilaterationEngine"]

View File

@ -0,0 +1,268 @@
"""Multilateration engine for BLE device positioning."""
import time
import re
from typing import Optional
import numpy as np
from scipy.optimize import minimize
class MultilaterationEngine:
"""
Calculates target position from multiple BLE scanner RSSI readings.
Uses the log-distance path loss model to convert RSSI to distance,
then weighted least squares optimization for position estimation.
"""
def __init__(self, rssi_at_1m: float = -40, path_loss_n: float = 2.5, smoothing_window: int = 5):
"""
Initialize the trilateration engine.
Args:
rssi_at_1m: RSSI value at 1 meter distance (calibration, typically -40 to -50)
path_loss_n: Path loss exponent (2.0 free space, 2.5-3.5 indoors)
smoothing_window: Number of readings to average for noise reduction
"""
self.rssi_at_1m = rssi_at_1m
self.path_loss_n = path_loss_n
self.smoothing_window = smoothing_window
# Scanner data: {scanner_id: {"position": (x, y), "rssi_history": [], "last_seen": timestamp}}
self.scanners: dict = {}
# Last calculated target position
self._last_target: Optional[dict] = None
self._last_calculation: float = 0
def parse_data(self, raw_data: str) -> int:
"""
Parse raw trilateration data from ESP32.
Format: ESP_ID;(x,y);rssi\n
Example: ESP3;(10.0,0.0);-45
Args:
raw_data: Raw text data with one or more readings
Returns:
Number of readings successfully processed
"""
pattern = re.compile(r'^(\w+);\(([0-9.+-]+),([0-9.+-]+)\);(-?\d+)$')
count = 0
timestamp = time.time()
for line in raw_data.strip().split('\n'):
line = line.strip()
if not line:
continue
match = pattern.match(line)
if match:
scanner_id = match.group(1)
x = float(match.group(2))
y = float(match.group(3))
rssi = int(match.group(4))
self.add_reading(scanner_id, x, y, rssi, timestamp)
count += 1
return count
def add_reading(self, scanner_id: str, x: float, y: float, rssi: int, timestamp: float = None):
"""
Add a new RSSI reading from a scanner.
Args:
scanner_id: Unique identifier for the scanner (e.g., "ESP1")
x: X coordinate of the scanner
y: Y coordinate of the scanner
rssi: RSSI value (negative dBm)
timestamp: Reading timestamp (defaults to current time)
"""
if timestamp is None:
timestamp = time.time()
if scanner_id not in self.scanners:
self.scanners[scanner_id] = {
"position": (x, y),
"rssi_history": [],
"last_seen": timestamp
}
scanner = self.scanners[scanner_id]
scanner["position"] = (x, y)
scanner["rssi_history"].append(rssi)
scanner["last_seen"] = timestamp
# Keep only recent readings for smoothing
if len(scanner["rssi_history"]) > self.smoothing_window:
scanner["rssi_history"] = scanner["rssi_history"][-self.smoothing_window:]
def rssi_to_distance(self, rssi: float) -> float:
"""
Convert RSSI to estimated distance using log-distance path loss model.
d = 10^((RSSI_1m - RSSI) / (10 * n))
Args:
rssi: RSSI value (negative dBm)
Returns:
Estimated distance in meters
"""
return 10 ** ((self.rssi_at_1m - rssi) / (10 * self.path_loss_n))
def calculate_position(self) -> dict:
"""
Calculate target position using trilateration.
Requires at least 3 active scanners with recent readings.
Uses weighted least squares optimization.
Returns:
dict with position, confidence, and scanner info, or error
"""
# Get active scanners (those with readings)
active_scanners = [
(sid, s) for sid, s in self.scanners.items()
if s["rssi_history"]
]
if len(active_scanners) < 3:
return {
"error": f"Need at least 3 active scanners (have {len(active_scanners)})",
"scanners_count": len(active_scanners)
}
# Prepare data arrays
positions = []
distances = []
weights = []
for scanner_id, scanner in active_scanners:
x, y = scanner["position"]
# Average RSSI for noise reduction
avg_rssi = sum(scanner["rssi_history"]) / len(scanner["rssi_history"])
distance = self.rssi_to_distance(avg_rssi)
positions.append([x, y])
distances.append(distance)
# Weight by signal strength (stronger signal = more reliable)
# Using inverse square of absolute RSSI
weights.append(1.0 / (abs(avg_rssi) ** 2))
positions = np.array(positions)
distances = np.array(distances)
weights = np.array(weights)
weights = weights / weights.sum() # Normalize weights
# Cost function: weighted sum of squared distance errors
def cost_function(point):
x, y = point
estimated_distances = np.sqrt((positions[:, 0] - x)**2 + (positions[:, 1] - y)**2)
errors = (estimated_distances - distances) ** 2
return np.sum(weights * errors)
# Initial guess: weighted centroid of scanner positions
x0 = np.sum(weights * positions[:, 0])
y0 = np.sum(weights * positions[:, 1])
# Optimize
result = minimize(cost_function, [x0, y0], method='L-BFGS-B')
if result.success:
target_x, target_y = result.x
# Confidence: inverse of residual error (higher cost = lower confidence)
confidence = 1.0 / (1.0 + result.fun)
self._last_target = {
"x": round(float(target_x), 2),
"y": round(float(target_y), 2)
}
self._last_calculation = time.time()
return {
"position": self._last_target,
"confidence": round(float(confidence), 3),
"scanners_used": len(active_scanners),
"calculated_at": self._last_calculation
}
else:
return {
"error": "Optimization failed",
"details": result.message
}
def get_state(self) -> dict:
"""
Get the current state of the trilateration system.
Returns:
dict with scanner info and last target position
"""
now = time.time()
scanners_data = []
for scanner_id, scanner in self.scanners.items():
avg_rssi = None
distance = None
if scanner["rssi_history"]:
avg_rssi = sum(scanner["rssi_history"]) / len(scanner["rssi_history"])
distance = round(self.rssi_to_distance(avg_rssi), 2)
avg_rssi = round(avg_rssi, 1)
scanners_data.append({
"id": scanner_id,
"position": {"x": scanner["position"][0], "y": scanner["position"][1]},
"last_rssi": avg_rssi,
"estimated_distance": distance,
"last_seen": scanner["last_seen"],
"age_seconds": round(now - scanner["last_seen"], 1)
})
result = {
"scanners": scanners_data,
"scanners_count": len(scanners_data),
"target": None,
"config": {
"rssi_at_1m": self.rssi_at_1m,
"path_loss_n": self.path_loss_n,
"smoothing_window": self.smoothing_window
}
}
# Add target if available
if self._last_target and (now - self._last_calculation) < 60:
result["target"] = {
"position": self._last_target,
"calculated_at": self._last_calculation,
"age_seconds": round(now - self._last_calculation, 1)
}
return result
def update_config(self, rssi_at_1m: float = None, path_loss_n: float = None, smoothing_window: int = None):
"""
Update trilateration configuration parameters.
Args:
rssi_at_1m: New RSSI at 1m value
path_loss_n: New path loss exponent
smoothing_window: New smoothing window size
"""
if rssi_at_1m is not None:
self.rssi_at_1m = rssi_at_1m
if path_loss_n is not None:
self.path_loss_n = path_loss_n
if smoothing_window is not None:
self.smoothing_window = max(1, smoothing_window)
def clear(self):
"""Clear all scanner data and reset state."""
self.scanners.clear()
self._last_target = None
self._last_calculation = 0

338
tools/c2/web/server.py Normal file
View File

@ -0,0 +1,338 @@
"""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 .multilateration import MultilaterationEngine
# 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,
multilateration_engine: Optional[MultilaterationEngine] = 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 multilateration API
device_registry: DeviceRegistry instance for device listing
multilateration_engine: MultilaterationEngine 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.multilat = multilateration_engine or MultilaterationEngine()
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.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():
# 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("/multilateration")
@require_login
def multilateration():
return render_template("multilateration.html", active_page="multilateration")
# ========== Static Files ==========
@app.route("/streams/<filename>")
@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/multilat/collect", methods=["POST"])
@require_api_auth
def api_multilat_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.multilat.parse_data(raw_data)
# Recalculate position after new data
if count > 0:
web_server.multilat.calculate_position()
return jsonify({
"status": "ok",
"readings_processed": count
})
@app.route("/api/multilat/state")
@require_api_auth
def api_multilat_state():
"""Get current multilateration state (scanners + target)."""
state = web_server.multilat.get_state()
# Include latest calculation if not present
if state["target"] is None and state["scanners_count"] >= 3:
result = web_server.multilat.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/multilat/config", methods=["GET", "POST"])
@require_api_auth
def api_multilat_config():
"""Get or update multilateration configuration."""
if request.method == "POST":
data = request.get_json() or {}
web_server.multilat.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.multilat.rssi_at_1m,
"path_loss_n": web_server.multilat.path_loss_n,
"smoothing_window": web_server.multilat.smoothing_window
})
@app.route("/api/multilat/clear", methods=["POST"])
@require_api_auth
def api_multilat_clear():
"""Clear all multilateration data."""
web_server.multilat.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.multilat.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}"