diff --git a/espilon_bot/components/command/command.c b/espilon_bot/components/command/command.c index f06870b..f309203 100644 --- a/espilon_bot/components/command/command.c +++ b/espilon_bot/components/command/command.c @@ -1,13 +1,25 @@ #include "command.h" #include "utils.h" #include "esp_log.h" + #include +#include 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 qu’on 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); } diff --git a/espilon_bot/components/core/gprs.c b/espilon_bot/components/core/gprs.c index a2aeba1..be36983 100644 --- a/espilon_bot/components/core/gprs.c +++ b/espilon_bot/components/core/gprs.c @@ -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 diff --git a/espilon_bot/components/mod_recon/mod_cam.c b/espilon_bot/components/mod_recon/mod_cam.c index a819332..fa4985c 100644 --- a/espilon_bot/components/mod_recon/mod_cam.c +++ b/espilon_bot/components/mod_recon/mod_cam.c @@ -13,6 +13,8 @@ #include #include #include +#include +#include #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, diff --git a/espilon_bot/main/bot-lwip.c b/espilon_bot/main/bot-lwip.c index d8b3f07..6a2a190 100644 --- a/espilon_bot/main/bot-lwip.c +++ b/espilon_bot/main/bot-lwip.c @@ -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 diff --git a/tools/c2/c3po.py b/tools/c2/c3po.py index c8e82c2..7ab5f25 100644 --- a/tools/c2/c3po.py +++ b/tools/c2/c3po.py @@ -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 diff --git a/tools/c2/camera/config.py b/tools/c2/camera/config.py index 641999a..b88afc3 100644 --- a/tools/c2/camera/config.py +++ b/tools/c2/camera/config.py @@ -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") diff --git a/tools/c2/camera/server.py b/tools/c2/camera/server.py index 3173107..36c82e7 100644 --- a/tools/c2/camera/server.py +++ b/tools/c2/camera/server.py @@ -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 diff --git a/tools/c2/camera/udp_receiver.py b/tools/c2/camera/udp_receiver.py index 95f6954..db019d8 100644 --- a/tools/c2/camera/udp_receiver.py +++ b/tools/c2/camera/udp_receiver.py @@ -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, diff --git a/tools/c2/cli/cli.py b/tools/c2/cli/cli.py index 8d561f1..28ca46e 100644 --- a/tools/c2/cli/cli.py +++ b/tools/c2/cli/cli.py @@ -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"]: diff --git a/tools/c2/core/transport.py b/tools/c2/core/transport.py index 65d141c..e063273 100644 --- a/tools/c2/core/transport.py +++ b/tools/c2/core/transport.py @@ -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 diff --git a/tools/c2/log/__init__.py b/tools/c2/log/__init__.py new file mode 100644 index 0000000..f1ca535 --- /dev/null +++ b/tools/c2/log/__init__.py @@ -0,0 +1,3 @@ +from .manager import LogManager + +__all__ = ["LogManager"] diff --git a/tools/c2/log/manager.py b/tools/c2/log/manager.py new file mode 100644 index 0000000..7b4311b --- /dev/null +++ b/tools/c2/log/manager.py @@ -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()) diff --git a/tools/c2/static/css/main.css b/tools/c2/static/css/main.css new file mode 100644 index 0000000..958d510 --- /dev/null +++ b/tools/c2/static/css/main.css @@ -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); +} diff --git a/tools/c2/static/js/multilateration.js b/tools/c2/static/js/multilateration.js new file mode 100644 index 0000000..2c3a125 --- /dev/null +++ b/tools/c2/static/js/multilateration.js @@ -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 = '

No scanners active

'; + return; + } + + list.innerHTML = scanners.map(s => ` +
+
${s.id}
+
+ 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' : '-'} +
+
+ `).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); diff --git a/tools/c2/static/record.avi b/tools/c2/static/record.avi new file mode 100644 index 0000000..e042023 Binary files /dev/null and b/tools/c2/static/record.avi differ diff --git a/tools/c2/static/streams/192.168.1.47_58642.jpg b/tools/c2/static/streams/192.168.1.47_58642.jpg new file mode 100644 index 0000000..2004d22 Binary files /dev/null and b/tools/c2/static/streams/192.168.1.47_58642.jpg differ diff --git a/tools/c2/templates/base.html b/tools/c2/templates/base.html new file mode 100644 index 0000000..42cbd85 --- /dev/null +++ b/tools/c2/templates/base.html @@ -0,0 +1,52 @@ + + + + + + {% block title %}ESPILON{% endblock %} + + {% block head %}{% endblock %} + + +
+ + +
+
+
+ - device(s) +
+ Logout +
+
+ +
+ {% block content %}{% endblock %} +
+ + + {% block scripts %}{% endblock %} + + diff --git a/tools/c2/templates/cameras.html b/tools/c2/templates/cameras.html new file mode 100644 index 0000000..1b00670 --- /dev/null +++ b/tools/c2/templates/cameras.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}Cameras - ESPILON{% endblock %} + +{% block content %} + + +{% if image_files %} +
+ {% for img in image_files %} +
+
+ {{ img.replace('.jpg', '').replace('_', ':') }} + LIVE +
+
+ +
+
+ {% endfor %} +
+{% else %} +
+

No active cameras

+

Waiting for ESP32-CAM devices to send frames on UDP port 5000

+
+{% endif %} +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/tools/c2/templates/dashboard.html b/tools/c2/templates/dashboard.html new file mode 100644 index 0000000..1e3a4cd --- /dev/null +++ b/tools/c2/templates/dashboard.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - ESPILON{% endblock %} + +{% block content %} + + +
+ +
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/tools/c2/templates/index.html b/tools/c2/templates/index.html deleted file mode 100644 index 92d3956..0000000 --- a/tools/c2/templates/index.html +++ /dev/null @@ -1,212 +0,0 @@ - - - - - - Cameras - ESPILON - - - -
- -
-
-
- {{ image_files|length }} camera(s) -
- Logout -
-
- -
-
-
Cameras Live Feed
-
- - {% if image_files %} -
- {% for img in image_files %} -
-
- {{ img.replace('.jpg', '').replace('_', ':') }} - LIVE -
-
- -
-
- {% endfor %} -
- {% else %} -
-

No active cameras

-

Waiting for ESP devices to send frames on UDP port 5000

-
- {% endif %} -
- - - - diff --git a/tools/c2/templates/login.html b/tools/c2/templates/login.html index 6e9aae7..8df1bfe 100644 --- a/tools/c2/templates/login.html +++ b/tools/c2/templates/login.html @@ -4,92 +4,9 @@ Login - ESPILON - + - + - + diff --git a/tools/c2/templates/multilateration.html b/tools/c2/templates/multilateration.html new file mode 100644 index 0000000..9c0196f --- /dev/null +++ b/tools/c2/templates/multilateration.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} + +{% block title %}Multilateration - ESPILON{% endblock %} + +{% block content %} + + +
+
+ +
+ +
+ +
+

Target Position

+
+ X + - +
+
+ Y + - +
+
+ Confidence + - +
+
+ Last Update + - +
+
+ + +
+

Active Scanners (0)

+
+
+

No scanners active

+
+
+
+ + +
+

Configuration

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/tools/c2/test_udp.py b/tools/c2/test_udp.py new file mode 100644 index 0000000..c7f58ce --- /dev/null +++ b/tools/c2/test_udp.py @@ -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() diff --git a/tools/c2/web/__init__.py b/tools/c2/web/__init__.py new file mode 100644 index 0000000..4a321ff --- /dev/null +++ b/tools/c2/web/__init__.py @@ -0,0 +1,6 @@ +"""Unified web server module for ESPILON C2.""" + +from .server import UnifiedWebServer +from .multilateration import MultilaterationEngine + +__all__ = ["UnifiedWebServer", "MultilaterationEngine"] diff --git a/tools/c2/web/multilateration.py b/tools/c2/web/multilateration.py new file mode 100644 index 0000000..6ea3841 --- /dev/null +++ b/tools/c2/web/multilateration.py @@ -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 diff --git a/tools/c2/web/server.py b/tools/c2/web/server.py new file mode 100644 index 0000000..688531e --- /dev/null +++ b/tools/c2/web/server.py @@ -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/") + @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}"